Files
marketing/src/views/pages/marketing/MarketingAnalysis.vue
T
2025-12-20 21:24:40 +05:00

3485 lines
160 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="marketing-analysis-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1200px] mx-auto">
<!-- Форма запуска анализа -->
<div v-if="!analysisId" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Запуск анализа</h2>
</div>
<div class="card-content">
<form @submit.prevent="handleSubmit" class="space-y-4 max-w-2xl">
<div class="field">
<label for="businessNiche" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Ниша бизнеса * </label>
<InputText id="businessNiche" v-model="formData.businessNiche" placeholder="Например: E-commerce платформы" class="w-full" :class="{ 'p-invalid': errors.businessNiche }" />
<small v-if="errors.businessNiche" class="p-error">{{ errors.businessNiche }}</small>
</div>
<div class="field">
<label for="product" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Что вы продаете? * </label>
<InputText id="product" v-model="formData.product" placeholder="Введите название продукта или услуги" class="w-full" :class="{ 'p-invalid': errors.product }" />
<small v-if="errors.product" class="p-error">{{ errors.product }}</small>
</div>
<div class="field">
<label class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Целевая аудитория * </label>
<!-- Гендер -->
<div class="mb-3">
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Гендер:</label>
<div class="flex gap-4 flex-wrap">
<div v-for="gender in VALID_GENDERS" :key="gender" class="flex align-items-center">
<Checkbox :inputId="`gender-${gender}`" :value="gender" v-model="formData.targetAudience.genders" />
<label :for="`gender-${gender}`" class="ml-2 cursor-pointer">{{ gender }}</label>
</div>
</div>
</div>
<!-- Возраст -->
<div class="mb-3">
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Возраст:</label>
<div class="flex gap-4 flex-wrap">
<div v-for="age in VALID_AGE_RANGES" :key="age" class="flex align-items-center">
<Checkbox :inputId="`age-${age}`" :value="age" v-model="formData.targetAudience.ageRanges" />
<label :for="`age-${age}`" class="ml-2 cursor-pointer">{{ age }}</label>
</div>
</div>
</div>
<!-- Тип -->
<div class="mb-3">
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Тип:</label>
<div class="flex gap-4 flex-wrap">
<div v-for="type in VALID_AUDIENCE_TYPES" :key="type" class="flex align-items-center">
<Checkbox :inputId="`type-${type}`" :value="type" v-model="formData.targetAudience.types" />
<label :for="`type-${type}`" class="ml-2 cursor-pointer">{{ type }}</label>
</div>
</div>
</div>
<!-- Отображение выбранных значений -->
<div v-if="formData.targetAudience.genders.length > 0 || formData.targetAudience.ageRanges.length > 0 || formData.targetAudience.types.length > 0" class="mt-2 p-2 border-round bg-surface-100 dark:bg-surface-800">
<small class="text-surface-600 dark:text-surface-400">
<strong>Выбрано:</strong>
<span v-if="formData.targetAudience.genders.length > 0"> Гендер: {{ formData.targetAudience.genders.join(', ') }}</span>
<span v-if="formData.targetAudience.ageRanges.length > 0"> Возраст: {{ formData.targetAudience.ageRanges.join(', ') }}</span>
<span v-if="formData.targetAudience.types.length > 0"> Тип: {{ formData.targetAudience.types.join(', ') }}</span>
</small>
</div>
<small v-if="errors.targetAudience" class="p-error">{{ errors.targetAudience }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите хотя бы одну опцию для целевой аудитории</small>
</div>
<div class="field">
<label for="region" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Регион (город Казахстана) * </label>
<MultiSelect id="region" v-model="formData.region" :options="VALID_REGIONS" placeholder="Выберите регионы" class="w-full" display="chip" :class="{ 'p-invalid': errors.region }" />
<small v-if="errors.region" class="p-error">{{ errors.region }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите один или несколько регионов</small>
</div>
<div class="field">
<label for="goal" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Цель на 6-12 месяцев * </label>
<Textarea id="goal" v-model="formData.goal" placeholder="Опишите вашу цель на ближайшие 6-12 месяцев" rows="3" class="w-full" :class="{ 'p-invalid': errors.goal }" />
<small v-if="errors.goal" class="p-error">{{ errors.goal }}</small>
</div>
<div class="field">
<label class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Уровень детализации анализа * </label>
<div class="flex gap-4">
<div v-for="level in DETAIL_LEVELS" :key="level" class="flex align-items-center">
<RadioButton :inputId="`detailLevel-${level}`" v-model="formData.detailLevel" :value="level" :class="{ 'p-invalid': errors.detailLevel }" />
<label :for="`detailLevel-${level}`" class="ml-2 cursor-pointer">
{{ level === 'КРАТКО' ? 'Кратко' : level === 'СТАНДАРТНО' ? 'Стандартно' : 'Подробно' }}
</label>
</div>
</div>
<small v-if="errors.detailLevel" class="p-error">{{ errors.detailLevel }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите уровень детализации анализа</small>
</div>
<div class="field">
<label for="strongSide" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Сильная сторона бизнеса </label>
<Textarea id="strongSide" v-model="formData.strongSide" placeholder="Опишите сильные стороны вашего бизнеса (опционально)" rows="2" class="w-full" :class="{ 'p-invalid': errors.strongSide }" />
<small v-if="errors.strongSide" class="p-error">{{ errors.strongSide }}</small>
</div>
<div class="field">
<label for="weakSide" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Слабая сторона бизнеса </label>
<Textarea id="weakSide" v-model="formData.weakSide" placeholder="Опишите слабые стороны вашего бизнеса (опционально)" rows="2" class="w-full" :class="{ 'p-invalid': errors.weakSide }" />
<small v-if="errors.weakSide" class="p-error">{{ errors.weakSide }}</small>
</div>
<div class="field">
<label for="analysisType" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Тип анализа * </label>
<MultiSelect id="analysisType" v-model="formData.analysisType" :options="VALID_ANALYSIS_TYPES" placeholder="Выберите типы анализа" class="w-full" display="chip" :class="{ 'p-invalid': errors.analysisType }">
<template #value="slotProps">
<div v-if="slotProps.value && slotProps.value.length > 0" class="flex flex-wrap gap-2">
<Tag v-for="type in slotProps.value" :key="type" :value="getAnalysisTypeLabel(type)" severity="info" />
</div>
<span v-else class="text-surface-400">{{ slotProps.placeholder }}</span>
</template>
<template #option="slotProps">
<div class="flex align-items-center">
<span>{{ getAnalysisTypeLabel(slotProps.option) }}</span>
</div>
</template>
</MultiSelect>
<small v-if="errors.analysisType" class="p-error">{{ errors.analysisType }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите один или несколько типов анализа. При выборе нескольких типов будут созданы отдельные анализы для каждого типа.</small>
</div>
<Button type="submit" label="Начать анализ" icon="pi pi-send" class="w-full p-button-primary" :loading="submitting" />
<p class="text-sm text-surface-500 dark:text-surface-400 mt-3 text-center">На основе ваших ответов будет создан подробный и полезный маркетинговый отчет</p>
</form>
</div>
</div>
<!-- Статус обработки -->
<div v-else-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>
<div class="card-content">
<div class="text-center py-6">
<ProgressSpinner size="50" />
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Анализ обрабатывается...</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Это может занять 5-10 минут. Пожалуйста, подождите.</p>
<p v-if="estimatedCompletionTime" class="mt-2 text-sm text-surface-500 dark:text-surface-400">Ожидаемое время завершения: {{ formatDateTime(estimatedCompletionTime) }}</p>
<Button label="Отменить" icon="pi pi-times" severity="secondary" class="mt-4" @click="resetAnalysis" />
</div>
</div>
</div>
<!-- Ошибка -->
<div v-else-if="status === 'failed'" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Ошибка анализа</h2>
</div>
<div class="card-content">
<div class="text-center py-6">
<i class="pi pi-exclamation-triangle text-6xl text-red-500 mb-3"></i>
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Анализ завершился с ошибкой</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Попробуйте запустить анализ снова или обратитесь в поддержку.</p>
<Button label="Попробовать снова" icon="pi pi-refresh" class="mt-4" @click="resetAnalysis" />
</div>
</div>
</div>
<!-- Результаты анализа -->
<div v-else-if="status === 'completed' && report" class="space-y-4">
<!-- Заголовок с кнопкой скачивания -->
<div class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<div>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Результаты анализа</h2>
<div v-if="currentAnalysisType" class="mt-2">
<Tag :value="getAnalysisTypeLabel(currentAnalysisType)" severity="info" />
</div>
</div>
<Button label="Скачать PDF" icon="pi pi-download" @click="handleDownloadPdf" :loading="downloadingPdf" />
</div>
</div>
</div>
<!-- Краткое резюме (Summary) -->
<div v-if="report.summary" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-file-edit mr-2"></i>Краткое резюме</h3>
</div>
<div class="card-content">
<div class="markdown-content" v-html="renderMarkdown(report.summary)"></div>
</div>
</div>
<!-- Динамическое содержимое отчета (в одном контейнере) -->
<div v-if="parsedContent.length" class="card">
<div class="card-content">
<div class="space-y-6">
<div v-for="(segment, index) in displayContent" :key="index">
<!-- Two-column pair: paragraph text + adjacent json-chart/table -->
<div
v-if="segment.type === 'paired'"
class="report-pair"
:class="{
'report-pair--mobile-chart-first': segment.mobileOrder === 'chart-first',
'report-pair--mobile-text-first': segment.mobileOrder === 'text-first'
}"
>
<!-- IMPORTANT: media first so text can wrap around float -->
<div class="report-pair__media">
<div v-if="segment.media.type === 'chart'">
<div class="mb-2" v-if="segment.media.title">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">{{ segment.media.title }}</h3>
</div>
<div class="report-pair__mediaChart">
<div class="chart-container report-pair__chart-container">
<Chart :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
</div>
</div>
<div v-if="segment.media.key === 'channelsPotential' && segment.media.channels?.length" class="mt-4 space-y-2">
<div v-for="channel in segment.media.channels" :key="channel.name" class="p-3 border-round bg-surface-50 dark:bg-surface-800">
<div class="flex justify-content-between align-items-center mb-2">
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ channel.name }}</span>
<Tag :value="`Потенциал: ${channel.potential}`" :severity="channel.potential >= 80 ? 'success' : channel.potential >= 60 ? 'warning' : 'info'" />
</div>
<p class="text-sm text-surface-600 dark:text-surface-400">{{ channel.justification }}</p>
</div>
</div>
</div>
<div v-else-if="segment.media.type === 'marketShareChart'">
<div class="mb-2" v-if="segment.media.title">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-chart-pie mr-2"></i>{{ segment.media.title }}</h3>
</div>
<div class="report-pair__mediaChart">
<div class="chart-container report-pair__chart-container">
<Chart :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
</div>
</div>
</div>
<div v-else-if="segment.media.type === 'comparisonTable'">
<div class="mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-table mr-2"></i>Сравнительная таблица</h3>
</div>
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
<table class="comparison-table w-full border-collapse">
<thead>
<tr>
<th class="comparison-table-th">Характеристика</th>
<th class="comparison-table-th text-center">Мы</th>
<th class="comparison-table-th text-center">Конкурент A</th>
<th class="comparison-table-th text-center">Конкурент B</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in segment.media.data" :key="idx" class="comparison-table-tr">
<td class="comparison-table-td font-semibold">{{ row.feature }}</td>
<td class="comparison-table-td text-center">
<Tag :value="row.us" severity="info" />
</td>
<td class="comparison-table-td text-center">
<Tag :value="row.compA" :severity="row.compA === 'Высокая' || row.compA === 'Премиум' ? 'danger' : row.compA === 'Низкая' ? 'success' : 'warning'" />
</td>
<td class="comparison-table-td text-center">
<Tag :value="row.compB" :severity="row.compB === 'Высокая' || row.compB === 'Премиум' ? 'danger' : row.compB === 'Низкая' ? 'success' : 'warning'" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="report-pair__text">
<div class="markdown-content" v-html="segment.text.html"></div>
</div>
</div>
<div v-else-if="segment.type === 'markdown'">
<div class="markdown-content" v-html="segment.html"></div>
</div>
<div v-else-if="segment.type === 'chart'">
<div class="mb-4" v-if="segment.title">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">{{ segment.title }}</h3>
</div>
<div class="chart-container" :style="segment.chartHeight">
<Chart :type="segment.chartType" :data="segment.data" :options="segment.options" />
</div>
<div v-if="segment.key === 'channelsPotential' && segment.channels?.length" class="mt-4 space-y-2">
<div v-for="channel in segment.channels" :key="channel.name" class="p-3 border-round bg-surface-50 dark:bg-surface-800">
<div class="flex justify-content-between align-items-center mb-2">
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ channel.name }}</span>
<Tag :value="`Потенциал: ${channel.potential}`" :severity="channel.potential >= 80 ? 'success' : channel.potential >= 60 ? 'warning' : 'info'" />
</div>
<p class="text-sm text-surface-600 dark:text-surface-400">{{ channel.justification }}</p>
</div>
</div>
</div>
<div v-else-if="segment.type === 'audienceSegmentation'">
<div class="mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-users mr-2"></i>Сегментация целевой аудитории</h3>
</div>
<div class="grid">
<div v-if="segment.ageGroupsChartData" class="col-12 md:col-6 mb-4">
<div class="flex align-items-center mb-3">
<i class="pi pi-calendar text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">По возрасту</h4>
</div>
<div class="chart-container" style="height: 300px">
<Chart type="pie" :data="segment.ageGroupsChartData" :options="pieChartOptions" />
</div>
</div>
<div v-if="segment.gendersChartData" class="col-12 md:col-6 mb-4">
<div class="flex align-items-center mb-3">
<i class="pi pi-user text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">По полу</h4>
</div>
<div class="chart-container" style="height: 300px">
<Chart type="pie" :data="segment.gendersChartData" :options="pieChartOptions" />
</div>
</div>
</div>
<!-- Segments Channel Matrix (Heatmap) -->
<div v-if="segment.segmentsChannelMatrix" class="mt-6">
<div class="flex align-items-center mb-3">
<i class="pi pi-th-large text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Матрица каналов по сегментам</h4>
</div>
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
<table class="channel-matrix-table w-full border-collapse">
<thead>
<tr>
<th class="channel-matrix-th">Сегмент</th>
<th v-for="channel in segment.segmentsChannelMatrix.channels" :key="channel" class="channel-matrix-th text-center">
{{ channel }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in segment.segmentsChannelMatrix.data" :key="idx" class="channel-matrix-tr">
<td class="channel-matrix-td font-semibold">{{ row.segmentName }}</td>
<td v-for="channel in segment.segmentsChannelMatrix.channels" :key="channel" class="channel-matrix-td text-center">
<span class="channel-matrix-badge" :style="{ backgroundColor: getChannelMatrixColor(row[channel]) }">
{{ row[channel] || '-' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Key Takeaways -->
<div v-if="segment.keyTakeaways && segment.keyTakeaways.length > 0" class="mt-6">
<div class="flex align-items-center mb-3">
<i class="pi pi-lightbulb text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Ключевые выводы</h4>
</div>
<div class="p-4 border-round bg-primary-50 dark:bg-primary-900 border-1 border-primary-200 dark:border-primary-700">
<ul class="list-none p-0 m-0">
<li v-for="(takeaway, idx) in segment.keyTakeaways" :key="idx" class="flex align-items-start mb-3">
<i class="pi pi-check-circle text-primary-500 mr-2 mt-1 flex-shrink-0"></i>
<span class="text-surface-700 dark:text-surface-300">{{ takeaway }}</span>
</li>
</ul>
</div>
</div>
</div>
<div v-else-if="segment.type === 'competitors'">
<div class="mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-chart-line mr-2"></i>Анализ конкурентов</h3>
</div>
<!-- Market Share Chart (if included in competitors segment) -->
<div v-if="segment.marketShareChart" class="mb-6">
<div class="flex align-items-center mb-3">
<i class="pi pi-chart-pie text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ segment.marketShareChart.title }}</h4>
</div>
<div class="chart-container" :style="segment.marketShareChart.chartHeight">
<Chart :type="segment.marketShareChart.chartType" :data="segment.marketShareChart.data" :options="segment.marketShareChart.options" />
</div>
</div>
<!-- Comparison Table (if included in competitors segment) -->
<div v-if="segment.comparisonTable" class="mb-6">
<div class="flex align-items-center mb-3">
<i class="pi pi-table text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Сравнительная таблица</h4>
</div>
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
<table class="comparison-table w-full border-collapse">
<thead>
<tr>
<th class="comparison-table-th">Характеристика</th>
<th class="comparison-table-th text-center">Мы</th>
<th class="comparison-table-th text-center">Конкурент A</th>
<th class="comparison-table-th text-center">Конкурент B</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in segment.comparisonTable.data" :key="idx" class="comparison-table-tr">
<td class="comparison-table-td font-semibold">{{ row.feature }}</td>
<td class="comparison-table-td text-center">
<Tag :value="row.us" severity="info" />
</td>
<td class="comparison-table-td text-center">
<Tag :value="row.compA" :severity="row.compA === 'Высокая' || row.compA === 'Премиум' ? 'danger' : row.compA === 'Низкая' ? 'success' : 'warning'" />
</td>
<td class="comparison-table-td text-center">
<Tag :value="row.compB" :severity="row.compB === 'Высокая' || row.compB === 'Премиум' ? 'danger' : row.compB === 'Низкая' ? 'success' : 'warning'" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="grid">
<div v-for="competitor in segment.competitors" :key="competitor.name" class="col-12 md:col-6 lg:col-4 mb-4">
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full flex flex-column">
<h4 class="text-lg font-semibold mb-3 text-surface-900 dark:text-surface-0 flex align-items-center"><i class="pi pi-building text-primary-500 mr-2"></i>{{ competitor.name }}</h4>
<div class="space-y-2 mb-3 flex-grow-1">
<div class="flex justify-content-between align-items-center">
<span class="text-surface-600 dark:text-surface-400 flex align-items-center"> <i class="pi pi-eye mr-1"></i>Охват: </span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.reach?.toLocaleString() || 0 }}</span>
</div>
<div v-if="competitor.followers" class="flex justify-content-between align-items-center">
<span class="text-surface-600 dark:text-surface-400 flex align-items-center"> <i class="pi pi-users mr-1"></i>Подписчики: </span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.followers?.toLocaleString() || 0 }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="text-surface-600 dark:text-surface-400 flex align-items-center"> <i class="pi pi-chart-bar mr-1"></i>Активность: </span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.activity }}%</span>
</div>
<div v-if="competitor.priceStrategy" class="flex justify-content-between align-items-center">
<span class="text-surface-600 dark:text-surface-400 flex align-items-center"> <i class="pi pi-tag mr-1"></i>Ценовая стратегия: </span>
<Tag
:value="competitor.priceStrategy"
:severity="competitor.priceStrategy === 'Высокая' || competitor.priceStrategy === 'Премиум' ? 'danger' : competitor.priceStrategy === 'Низкая' ? 'success' : 'warning'"
/>
</div>
</div>
<div v-if="competitor.strengths?.length" class="mb-3">
<p class="text-sm font-semibold mb-2 text-green-600 dark:text-green-400 flex align-items-center"><i class="pi pi-arrow-up mr-1"></i>Сильные стороны:</p>
<ul class="list-none p-0 m-0">
<li v-for="(strength, idx) in competitor.strengths" :key="idx" class="text-sm text-surface-700 dark:text-surface-300 mb-1 flex align-items-start">
<i class="pi pi-check-circle text-green-500 mr-2 mt-1 flex-shrink-0"></i>
<span>{{ strength }}</span>
</li>
</ul>
</div>
<div v-if="competitor.weaknesses?.length">
<p class="text-sm font-semibold mb-2 text-red-600 dark:text-red-400 flex align-items-center"><i class="pi pi-arrow-down mr-1"></i>Слабые стороны:</p>
<ul class="list-none p-0 m-0">
<li v-for="(weakness, idx) in competitor.weaknesses" :key="idx" class="text-sm text-surface-700 dark:text-surface-300 mb-1 flex align-items-start">
<i class="pi pi-times-circle text-red-500 mr-2 mt-1 flex-shrink-0"></i>
<span>{{ weakness }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Competitors Heatmap -->
<div v-if="segment.competitorsHeatmap" class="mt-6">
<div class="flex align-items-center mb-3">
<i class="pi pi-map text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Тепловая карта каналов конкурентов</h4>
</div>
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
<table class="competitors-heatmap-table w-full border-collapse">
<thead>
<tr>
<th class="competitors-heatmap-th">Конкурент</th>
<th v-for="channel in segment.competitorsHeatmap.channels" :key="channel" class="competitors-heatmap-th text-center">
{{ channel }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(compData, idx) in segment.competitorsHeatmap.data" :key="idx" class="competitors-heatmap-tr">
<td class="competitors-heatmap-td font-semibold">{{ compData.name }}</td>
<td v-for="channel in segment.competitorsHeatmap.channels" :key="channel" class="competitors-heatmap-td text-center">
<span class="competitors-heatmap-badge" :style="{ backgroundColor: getHeatmapColor(compData.channelsHeatmap[channel] || 0) }">
{{ compData.channelsHeatmap[channel] || 0 }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Market Share Chart -->
<div v-else-if="segment.type === 'marketShareChart'">
<div class="mb-4" v-if="segment.title">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-chart-pie mr-2"></i>{{ segment.title }}</h3>
</div>
<div class="chart-container" :style="segment.chartHeight">
<Chart :type="segment.chartType" :data="segment.data" :options="segment.options" />
</div>
</div>
<!-- Comparison Table -->
<div v-else-if="segment.type === 'comparisonTable'">
<div class="mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-table mr-2"></i>Сравнительная таблица</h3>
</div>
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
<table class="comparison-table w-full border-collapse">
<thead>
<tr>
<th class="comparison-table-th">Характеристика</th>
<th class="comparison-table-th text-center">Мы</th>
<th class="comparison-table-th text-center">Конкурент A</th>
<th class="comparison-table-th text-center">Конкурент B</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in segment.data" :key="idx" class="comparison-table-tr">
<td class="comparison-table-td font-semibold">{{ row.feature }}</td>
<td class="comparison-table-td text-center">
<Tag :value="row.us" severity="info" />
</td>
<td class="comparison-table-td text-center">
<Tag :value="row.compA" :severity="row.compA === 'Высокая' || row.compA === 'Премиум' ? 'danger' : row.compA === 'Низкая' ? 'success' : 'warning'" />
</td>
<td class="comparison-table-td text-center">
<Tag :value="row.compB" :severity="row.compB === 'Высокая' || row.compB === 'Премиум' ? 'danger' : row.compB === 'Низкая' ? 'success' : 'warning'" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- SWOT Competitors -->
<div v-else-if="segment.type === 'swotCompetitors'">
<div class="mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-sitemap mr-2"></i>SWOT-анализ конкурентов</h3>
</div>
<div class="grid">
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-green-50 dark:bg-green-900 border-1 border-green-200 dark:border-green-700 h-full">
<h4 class="text-lg font-semibold mb-3 text-green-700 dark:text-green-300 flex align-items-center"><i class="pi pi-arrow-up mr-2"></i>Сильные стороны</h4>
<ul class="list-none p-0 m-0">
<li v-for="(strength, idx) in segment.data.strengths" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2 flex align-items-start">
<i class="pi pi-check-circle text-green-500 mr-2 mt-1 flex-shrink-0"></i>
<span>{{ strength }}</span>
</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-red-50 dark:bg-red-900 border-1 border-red-200 dark:border-red-700 h-full">
<h4 class="text-lg font-semibold mb-3 text-red-700 dark:text-red-300 flex align-items-center"><i class="pi pi-arrow-down mr-2"></i>Слабые стороны</h4>
<ul class="list-none p-0 m-0">
<li v-for="(weakness, idx) in segment.data.weaknesses" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2 flex align-items-start">
<i class="pi pi-times-circle text-red-500 mr-2 mt-1 flex-shrink-0"></i>
<span>{{ weakness }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Market -->
<div v-else-if="segment.type === 'market'">
<div class="mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-globe mr-2"></i>Анализ рынка</h3>
</div>
<div class="grid mb-4">
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
<div class="flex align-items-center mb-2">
<i class="pi pi-chart-bar text-primary-500 mr-2"></i>
<h4 class="text-sm font-semibold text-surface-600 dark:text-surface-400 m-0">Размер рынка</h4>
</div>
<p class="text-lg font-bold text-surface-900 dark:text-surface-0 mt-2">{{ segment.data.size || 'Не указано' }}</p>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
<div class="flex align-items-center mb-2">
<i class="pi pi-arrow-up-right text-primary-500 mr-2"></i>
<h4 class="text-sm font-semibold text-surface-600 dark:text-surface-400 m-0">Темп роста</h4>
</div>
<p class="text-lg font-bold text-surface-900 dark:text-surface-0 mt-2">{{ segment.data.growthRate || 'Не указано' }}</p>
</div>
</div>
</div>
<div v-if="segment.data.trends && segment.data.trends.length > 0" class="mb-4">
<div class="flex align-items-center mb-3">
<i class="pi pi-trending-up text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Тренды</h4>
</div>
<div class="p-4 border-round bg-blue-50 dark:bg-blue-900 border-1 border-blue-200 dark:border-blue-700">
<ul class="list-none p-0 m-0">
<li v-for="(trend, idx) in segment.data.trends" :key="idx" class="flex align-items-start mb-2">
<i class="pi pi-arrow-up-right text-blue-500 mr-2 mt-1 flex-shrink-0"></i>
<span class="text-surface-700 dark:text-surface-300">{{ trend }}</span>
</li>
</ul>
</div>
</div>
<div class="grid">
<div v-if="segment.data.opportunities && segment.data.opportunities.length > 0" class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-blue-50 dark:bg-blue-900 border-1 border-blue-200 dark:border-blue-700">
<h4 class="text-lg font-semibold mb-3 text-blue-700 dark:text-blue-300">Возможности</h4>
<ul class="list-none p-0 m-0">
<li v-for="(opportunity, idx) in segment.data.opportunities" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2">
<i class="pi pi-arrow-up-circle text-blue-500 mr-2"></i>{{ opportunity }}
</li>
</ul>
</div>
</div>
<div v-if="segment.data.threats && segment.data.threats.length > 0" class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-orange-50 dark:bg-orange-900 border-1 border-orange-200 dark:border-orange-700">
<h4 class="text-lg font-semibold mb-3 text-orange-700 dark:text-orange-300">Угрозы</h4>
<ul class="list-none p-0 m-0">
<li v-for="(threat, idx) in segment.data.threats" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-exclamation-triangle text-orange-500 mr-2"></i>{{ threat }}</li>
</ul>
</div>
</div>
</div>
</div>
<div v-else-if="segment.type === 'swot'">
<div class="mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">SWOT-анализ</h3>
</div>
<div class="grid">
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-green-50 dark:bg-green-900 border-1 border-green-200 dark:border-green-700">
<h4 class="text-lg font-semibold mb-3 text-green-700 dark:text-green-300">Сильные стороны</h4>
<ul class="list-none p-0 m-0">
<li v-for="(strength, idx) in segment.data.strengths" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-check-circle text-green-500 mr-2"></i>{{ strength }}</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-red-50 dark:bg-red-900 border-1 border-red-200 dark:border-red-700">
<h4 class="text-lg font-semibold mb-3 text-red-700 dark:text-red-300">Слабые стороны</h4>
<ul class="list-none p-0 m-0">
<li v-for="(weakness, idx) in segment.data.weaknesses" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-times-circle text-red-500 mr-2"></i>{{ weakness }}</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-blue-50 dark:bg-blue-900 border-1 border-blue-200 dark:border-blue-700">
<h4 class="text-lg font-semibold mb-3 text-blue-700 dark:text-blue-300">Возможности</h4>
<ul class="list-none p-0 m-0">
<li v-for="(opportunity, idx) in segment.data.opportunities" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2">
<i class="pi pi-arrow-up-circle text-blue-500 mr-2"></i>{{ opportunity }}
</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-orange-50 dark:bg-orange-900 border-1 border-orange-200 dark:border-orange-700">
<h4 class="text-lg font-semibold mb-3 text-orange-700 dark:text-orange-300">Угрозы</h4>
<ul class="list-none p-0 m-0">
<li v-for="(threat, idx) in segment.data.threats" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-exclamation-triangle text-orange-500 mr-2"></i>{{ threat }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Fallback for unrecognized segment types - don't display raw JSON -->
<div v-else-if="segment && segment.type">
<p class="text-surface-600 dark:text-surface-400">Неизвестный тип сегмента: {{ segment.type }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Рекомендации -->
<div v-if="report.recommendations?.length" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Рекомендации</h3>
</div>
<div class="card-content">
<ul class="list-none p-0 m-0">
<li v-for="(recommendation, index) in report.recommendations" :key="index" class="flex align-items-start mb-3">
<i class="pi pi-check-circle text-green-500 mr-2 mt-1"></i>
<span class="text-surface-700 dark:text-surface-300">{{ recommendation }}</span>
</li>
</ul>
</div>
</div>
<!-- Кнопки действий -->
<div class="card">
<div class="card-content text-center">
<div class="flex gap-3 justify-content-center flex-wrap">
<Button label="Создать стратегию на основе анализа" icon="pi pi-magic" severity="success" @click="goToStrategy" />
<Button label="Создать новый анализ" icon="pi pi-plus" severity="secondary" @click="resetAnalysis" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import MarketingService from '@/service/MarketingService';
import { marked } from 'marked';
import Button from 'primevue/button';
import Chart from 'primevue/chart';
import Checkbox from 'primevue/checkbox';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import ProgressSpinner from 'primevue/progressspinner';
import RadioButton from 'primevue/radiobutton';
import Tag from 'primevue/tag';
import Textarea from 'primevue/textarea';
import Toast from 'primevue/toast';
import { useToast } from 'primevue/usetoast';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const toast = useToast();
const router = useRouter();
const route = useRoute();
// Constants
const VALID_REGIONS = [
'Алматы',
'Астана',
'Шымкент',
'Караганда',
'Актобе',
'Тараз',
'Павлодар',
'Усть-Каменогорск',
'Семей',
'Костанай',
'Кызылорда',
'Уральск',
'Петропавловск',
'Атырау',
'Актау',
'Туркестан',
'Кокшетау',
'Талдыкорган',
'Экибастуз',
'Рудный'
];
const VALID_GENDERS = ['Женщины', 'Мужчины'];
const VALID_AGE_RANGES = ['20-40', '25-45', '18-25', '40-60', '60+'];
const VALID_AUDIENCE_TYPES = ['Семьи', 'Молодёжь', 'Все подряд'];
const VALID_ANALYSIS_TYPES = ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ', 'SWOT'];
const DETAIL_LEVELS = ['КРАТКО', 'СТАНДАРТНО', 'ПОДРОБНО'];
// Form data
const formData = ref({
businessNiche: '',
product: '',
targetAudience: {
genders: [],
ageRanges: [],
types: []
},
region: [],
goal: '',
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
analysisType: []
});
const analysisTypeOptions = [
{ label: 'РЫНОК - Анализ внешней среды', value: 'РЫНОК' },
{ label: 'КОНКУРЕНТЫ - Анализ конкурентов', value: 'КОНКУРЕНТЫ' },
{ label: 'ЦА - Анализ целевой аудитории', value: 'ЦА' },
{ label: 'КАНАЛЫ - Анализ рекламных каналов', value: 'КАНАЛЫ' },
{ label: 'SWOT - SWOT-анализ', value: 'SWOT' }
];
const submitting = ref(false);
const errors = ref({});
// Analysis state
const analysisId = ref(null);
const status = ref(null);
const report = ref(null);
const currentAnalysisType = ref(null);
const estimatedCompletionTime = ref(null);
const pollingInterval = ref(null);
const pollingAttempts = ref(0);
const maxPollingAttempts = 60; // 10 minutes with 10 second intervals
const pollingIntervalMs = 10000; // 10 seconds
const downloadingPdf = ref(false);
// Validation
const validateForm = () => {
errors.value = {};
// BusinessNiche validation
if (!formData.value.businessNiche || formData.value.businessNiche.trim().length < 3) {
errors.value.businessNiche = 'Ниша бизнеса должна содержать минимум 3 символа';
} else if (formData.value.businessNiche.trim().length > 200) {
errors.value.businessNiche = 'Ниша бизнеса не должна превышать 200 символов';
}
// Product validation
if (!formData.value.product || formData.value.product.trim().length < 3) {
errors.value.product = 'Название продукта должно содержать минимум 3 символа';
} else if (formData.value.product.trim().length > 200) {
errors.value.product = 'Название продукта не должно превышать 200 символов';
}
// TargetAudience validation
const hasGenders = formData.value.targetAudience.genders && formData.value.targetAudience.genders.length > 0;
const hasAgeRanges = formData.value.targetAudience.ageRanges && formData.value.targetAudience.ageRanges.length > 0;
const hasTypes = formData.value.targetAudience.types && formData.value.targetAudience.types.length > 0;
if (!hasGenders && !hasAgeRanges && !hasTypes) {
errors.value.targetAudience = 'Выберите хотя бы одну опцию для целевой аудитории (гендер, возраст или тип)';
} else {
// Валидация значений
if (formData.value.targetAudience.genders) {
const invalidGenders = formData.value.targetAudience.genders.filter((g) => !VALID_GENDERS.includes(g));
if (invalidGenders.length > 0) {
errors.value.targetAudience = `Недопустимые значения гендера: ${invalidGenders.join(', ')}`;
}
}
if (formData.value.targetAudience.ageRanges) {
const invalidAges = formData.value.targetAudience.ageRanges.filter((a) => !VALID_AGE_RANGES.includes(a));
if (invalidAges.length > 0) {
errors.value.targetAudience = `Недопустимые значения возраста: ${invalidAges.join(', ')}`;
}
}
if (formData.value.targetAudience.types) {
const invalidTypes = formData.value.targetAudience.types.filter((t) => !VALID_AUDIENCE_TYPES.includes(t));
if (invalidTypes.length > 0) {
errors.value.targetAudience = `Недопустимые значения типа: ${invalidTypes.join(', ')}`;
}
}
}
// Region validation
if (!formData.value.region || formData.value.region.length === 0) {
errors.value.region = 'Выберите хотя бы один регион';
} else {
const invalidRegions = formData.value.region.filter((r) => !VALID_REGIONS.includes(r));
if (invalidRegions.length > 0) {
errors.value.region = `Недопустимые регионы: ${invalidRegions.join(', ')}`;
}
}
// Goal validation
if (!formData.value.goal || formData.value.goal.trim().length < 10) {
errors.value.goal = 'Цель должна содержать минимум 10 символов';
} else if (formData.value.goal.trim().length > 500) {
errors.value.goal = 'Цель не должна превышать 500 символов';
}
// DetailLevel validation
if (!formData.value.detailLevel || !DETAIL_LEVELS.includes(formData.value.detailLevel)) {
errors.value.detailLevel = 'Пожалуйста, выберите уровень детализации';
}
// StrongSide validation (optional)
if (formData.value.strongSide && formData.value.strongSide.trim().length > 500) {
errors.value.strongSide = 'Сильная сторона не должна превышать 500 символов';
}
// WeakSide validation (optional)
if (formData.value.weakSide && formData.value.weakSide.trim().length > 500) {
errors.value.weakSide = 'Слабая сторона не должна превышать 500 символов';
}
// AnalysisType validation
if (!formData.value.analysisType || formData.value.analysisType.length === 0) {
errors.value.analysisType = 'Выберите хотя бы один тип анализа';
} else {
const invalidTypes = formData.value.analysisType.filter((t) => !VALID_ANALYSIS_TYPES.includes(t));
if (invalidTypes.length > 0) {
errors.value.analysisType = `Недопустимые типы анализа: ${invalidTypes.join(', ')}`;
}
}
return Object.keys(errors.value).length === 0;
};
// Start polling for analysis status
const startPolling = (id) => {
pollingAttempts.value = 0;
pollingInterval.value = setInterval(async () => {
pollingAttempts.value++;
if (pollingAttempts.value > maxPollingAttempts) {
stopPolling();
toast.add({
severity: 'warn',
summary: 'Таймаут',
detail: 'Превышено время ожидания анализа. Попробуйте проверить результат позже.',
life: 5000
});
return;
}
try {
const result = await MarketingService.getAnalysisResult(id);
status.value = result.status;
if (result.status === 'completed' && result.report) {
stopPolling();
report.value = result.report;
// Сохраняем тип анализа из результата, если он есть
if (result.analysisType) {
currentAnalysisType.value = result.analysisType;
}
toast.add({
severity: 'success',
summary: 'Анализ завершен',
detail: 'Маркетинговый анализ успешно завершен',
life: 3000
});
} else if (result.status === 'failed') {
stopPolling();
toast.add({
severity: 'error',
summary: 'Ошибка анализа',
detail: 'Анализ завершился с ошибкой',
life: 5000
});
}
} catch (error) {
console.error('Ошибка при проверке статуса анализа:', error);
// Continue polling on error, but log it
}
}, pollingIntervalMs);
};
// Stop polling
const stopPolling = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
pollingInterval.value = null;
}
};
// Handle form submit
const handleSubmit = async () => {
if (!validateForm()) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Пожалуйста, исправьте ошибки в форме',
life: 3000
});
return;
}
submitting.value = true;
try {
// Формируем объект targetAudience (удаляем пустые массивы)
const targetAudience = {};
if (formData.value.targetAudience.genders && formData.value.targetAudience.genders.length > 0) {
targetAudience.genders = formData.value.targetAudience.genders;
}
if (formData.value.targetAudience.ageRanges && formData.value.targetAudience.ageRanges.length > 0) {
targetAudience.ageRanges = formData.value.targetAudience.ageRanges;
}
if (formData.value.targetAudience.types && formData.value.targetAudience.types.length > 0) {
targetAudience.types = formData.value.targetAudience.types;
}
const requestData = {
businessNiche: formData.value.businessNiche.trim(),
product: formData.value.product.trim(),
targetAudience: targetAudience,
region: formData.value.region,
goal: formData.value.goal.trim(),
detailLevel: formData.value.detailLevel,
analysisType: formData.value.analysisType
};
// Добавляем опциональные поля только если они заполнены
if (formData.value.strongSide && formData.value.strongSide.trim()) {
requestData.strongSide = formData.value.strongSide.trim();
}
if (formData.value.weakSide && formData.value.weakSide.trim()) {
requestData.weakSide = formData.value.weakSide.trim();
}
const result = await MarketingService.startAnalysis(requestData);
// Обработка ответа: может быть массив или один объект
if (Array.isArray(result)) {
// Множественные анализы
const firstAnalysis = result[0];
analysisId.value = firstAnalysis.analysisId;
status.value = firstAnalysis.status;
estimatedCompletionTime.value = firstAnalysis.estimatedCompletion;
// Сохраняем типы анализов
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
toast.add({
severity: 'success',
summary: 'Анализы запущены',
detail: `Создано ${result.length} анализов. Они будут обработаны параллельно.`,
life: 5000
});
// Запускаем polling для первого анализа (можно расширить для всех)
startPolling(firstAnalysis.analysisId);
} else {
// Один анализ
analysisId.value = result.analysisId;
status.value = result.status;
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
estimatedCompletionTime.value = result.estimatedCompletion;
toast.add({
severity: 'success',
summary: 'Анализ запущен',
detail: result.message || 'Анализ успешно запущен. Результаты будут готовы в течение 5-10 минут.',
life: 5000
});
// Start polling
startPolling(result.analysisId);
}
} catch (error) {
console.error('Ошибка при запуске анализа:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить анализ. Пожалуйста, попробуйте снова.',
life: 5000
});
} finally {
submitting.value = false;
}
};
// Handle PDF download
const handleDownloadPdf = async () => {
if (!analysisId.value) return;
downloadingPdf.value = true;
try {
await MarketingService.downloadPdfFile(analysisId.value);
toast.add({
severity: 'success',
summary: 'PDF скачан',
detail: 'PDF отчет успешно скачан',
life: 3000
});
} catch (error) {
console.error('Ошибка при скачивании PDF:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось скачать PDF отчет',
life: 5000
});
} finally {
downloadingPdf.value = false;
}
};
// Reset analysis and form
const resetAnalysis = () => {
stopPolling();
analysisId.value = null;
status.value = null;
report.value = null;
currentAnalysisType.value = null;
estimatedCompletionTime.value = null;
pollingAttempts.value = 0;
formData.value = {
businessNiche: '',
product: '',
targetAudience: {
genders: [],
ageRanges: [],
types: []
},
region: [],
goal: '',
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
analysisType: []
};
errors.value = {};
};
// Render markdown to HTML
// Helper function to extract JSON from rendered HTML (after markdown rendering)
const extractJsonFromRenderedHtml = (html) => {
if (!html || typeof html !== 'string') {
return { htmlSegments: [html], chartSegments: [] };
}
const chartSegments = [];
const htmlSegments = [];
let processedHtml = html;
const processedMatches = new Set();
// Find all <pre><code> blocks that might contain JSON
const codeBlockRegex = /<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi;
let match;
while ((match = codeBlockRegex.exec(html)) !== null) {
const [fullMatch, codeContent] = match;
if (processedMatches.has(fullMatch)) continue;
// Try to extract and parse JSON from code content
const jsonSegment = tryParseJsonSegment(codeContent);
if (jsonSegment) {
processedMatches.add(fullMatch);
chartSegments.push(jsonSegment);
// Remove the code block from HTML
processedHtml = processedHtml.replace(fullMatch, '');
}
}
// Also check for standalone <code> blocks
const standaloneCodeRegex = /<code[^>]*>([\s\S]*?)<\/code>/gi;
while ((match = standaloneCodeRegex.exec(processedHtml)) !== null) {
const [fullMatch, codeContent] = match;
if (processedMatches.has(fullMatch)) continue;
const jsonSegment = tryParseJsonSegment(codeContent);
if (jsonSegment) {
processedMatches.add(fullMatch);
chartSegments.push(jsonSegment);
processedHtml = processedHtml.replace(fullMatch, '');
}
}
// Also try to find JSON in plain text (not in code blocks)
// Look for JSON objects/arrays in the remaining HTML text
const findJsonInHtmlText = (text) => {
const results = [];
let depth = 0;
let start = -1;
let inString = false;
let stringChar = '';
let inTag = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const prevChar = i > 0 ? text[i - 1] : '';
// Skip HTML tags
if (char === '<') inTag = true;
if (char === '>') inTag = false;
if (inTag) continue;
// Handle string escaping
if (prevChar === '\\') continue;
// Handle string boundaries
if ((char === '"' || char === "'") && !inString) {
inString = true;
stringChar = char;
} else if (char === stringChar && inString) {
inString = false;
stringChar = '';
}
if (inString) continue;
// Track bracket depth
if (char === '[' || char === '{') {
if (depth === 0) {
start = i;
}
depth++;
} else if (char === ']' || char === '}') {
depth--;
if (depth === 0 && start !== -1) {
const jsonText = text.substring(start, i + 1);
const jsonSegment = tryParseJsonSegment(jsonText);
if (jsonSegment) {
results.push({ text: jsonText, index: start, segment: jsonSegment });
}
start = -1;
}
}
}
return results;
};
const jsonMatches = findJsonInHtmlText(processedHtml);
// Process matches from end to start to preserve indices
jsonMatches.reverse().forEach(({ text, segment }) => {
if (!processedMatches.has(text)) {
processedMatches.add(text);
chartSegments.push(segment);
// Remove JSON from HTML
const index = processedHtml.indexOf(text);
if (index !== -1) {
processedHtml = processedHtml.substring(0, index) + processedHtml.substring(index + text.length);
}
}
});
// Split remaining HTML by removed code blocks and add as segments
if (processedHtml.trim()) {
// Split by multiple newlines to create separate segments
const parts = processedHtml.split(/\n\s*\n/).filter((p) => p.trim());
htmlSegments.push(...parts);
}
return { htmlSegments, chartSegments };
};
// Helper function to extract JSON from markdown before rendering
const extractJsonFromMarkdown = (markdown) => {
if (!markdown || typeof markdown !== 'string') {
return { processedText: markdown, jsonSegments: [] };
}
const jsonSegments = [];
let processedText = markdown;
const processedMatches = new Set();
// Find all code blocks that might contain JSON
// Match ```json, ```json:identifier, or plain ``` blocks
const codeBlockRegex = /```(?:json(?::(\w+))?)?\n([\s\S]*?)\n```/g;
let match;
while ((match = codeBlockRegex.exec(markdown)) !== null) {
const [fullMatch, identifier, codeContent] = match;
const jsonSegment = tryParseJsonSegment(codeContent);
if (jsonSegment && !processedMatches.has(fullMatch)) {
processedMatches.add(fullMatch);
jsonSegments.push(jsonSegment);
// Replace the code block with empty string (we'll add the segment separately)
processedText = processedText.replace(fullMatch, '');
}
}
// Also check for JSON objects/arrays that are not in code blocks
// Look for JSON at the end of text or standalone
// Use a more sophisticated approach: find balanced brackets
const findJsonInText = (text) => {
const results = [];
let depth = 0;
let start = -1;
let inString = false;
let stringChar = '';
for (let i = 0; i < text.length; i++) {
const char = text[i];
const prevChar = i > 0 ? text[i - 1] : '';
// Handle string escaping
if (prevChar === '\\') continue;
// Handle string boundaries
if ((char === '"' || char === "'") && !inString) {
inString = true;
stringChar = char;
} else if (char === stringChar && inString) {
inString = false;
stringChar = '';
}
if (inString) continue;
// Track bracket depth
if (char === '[' || char === '{') {
if (depth === 0) {
start = i;
}
depth++;
} else if (char === ']' || char === '}') {
depth--;
if (depth === 0 && start !== -1) {
const jsonText = text.substring(start, i + 1);
const jsonSegment = tryParseJsonSegment(jsonText);
if (jsonSegment) {
results.push({ text: jsonText, index: start, segment: jsonSegment });
}
start = -1;
}
}
}
return results;
};
const jsonMatches = findJsonInText(processedText);
// Process matches from end to start to preserve indices
jsonMatches.reverse().forEach(({ text, segment }) => {
if (!processedMatches.has(text)) {
processedMatches.add(text);
jsonSegments.push(segment);
// Use a more precise replacement to avoid partial matches
processedText = processedText.substring(0, processedText.indexOf(text)) + processedText.substring(processedText.indexOf(text) + text.length);
}
});
// Also check if the entire remaining text is JSON (common case when JSON is at the end)
// This handles cases where JSON is at the very end of the text
const finalTrimmed = processedText.trim();
if (finalTrimmed && (finalTrimmed.startsWith('[') || finalTrimmed.startsWith('{'))) {
const finalJsonSegment = tryParseJsonSegment(finalTrimmed);
if (finalJsonSegment && !processedMatches.has(finalTrimmed)) {
jsonSegments.push(finalJsonSegment);
processedText = '';
}
}
// Also check for JSON that might be at the end but after some whitespace/newlines
// Look for JSON patterns at the end of the text (after removing trailing whitespace)
const lines = processedText.split('\n');
let jsonStartLine = -1;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (line.startsWith('[') || line.startsWith('{')) {
jsonStartLine = i;
break;
}
if (line && !line.match(/^[\[\]{}:,\s"']+$/)) {
// Not a JSON line, stop searching
break;
}
}
if (jsonStartLine >= 0) {
const jsonText = lines.slice(jsonStartLine).join('\n').trim();
const finalJsonSegment = tryParseJsonSegment(jsonText);
if (finalJsonSegment && !processedMatches.has(jsonText)) {
jsonSegments.push(finalJsonSegment);
processedText = lines.slice(0, jsonStartLine).join('\n').trim();
}
}
return { processedText: processedText.trim(), jsonSegments };
};
const renderMarkdown = (markdown) => {
if (!markdown || typeof markdown !== 'string') {
return '';
}
try {
// В marked v17 используется marked.parse() с опциями вторым параметром
return marked.parse(markdown, { breaks: true, gfm: true });
} catch (error) {
console.error('Error rendering markdown:', error);
// В случае ошибки возвращаем исходный текст
return markdown;
}
};
const seasonalityChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top'
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function (value) {
return value + '%';
}
}
}
}
};
const conversionFunnelChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top'
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Количество пользователей'
}
}
}
};
const channelsPotentialChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function (value) {
return value;
}
}
}
}
};
const competitorsBarChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function (value) {
return value.toLocaleString();
}
}
}
}
};
const buildSeasonalityChartData = (seasonality) => {
if (!seasonality || typeof seasonality !== 'object') return null;
return {
labels: Object.keys(seasonality),
datasets: [
{
label: 'Индекс спроса',
data: Object.values(seasonality),
borderColor: '#3B82F6',
backgroundColor: 'rgba(59, 130, 246, 0.2)',
fill: true,
tension: 0.4
}
]
};
};
const buildConversionFunnelChartData = (funnel) => {
if (!Array.isArray(funnel) || funnel.length === 0) return null;
return {
labels: funnel.map((item) => item.stage),
datasets: [
{
label: 'Пользователей',
data: funnel.map((item) => item.value),
backgroundColor: '#3B82F6',
borderRadius: 4
}
]
};
};
const buildChannelsPotentialChartData = (channels) => {
if (!Array.isArray(channels) || channels.length === 0) return null;
return {
labels: channels.map((ch) => ch.name),
datasets: [
{
label: 'Потенциал канала',
data: channels.map((ch) => ch.potential),
backgroundColor: (ctx) => {
const val = ctx.raw;
return val >= 80 ? '#10B981' : val >= 50 ? '#F59E0B' : '#EF4444';
},
borderRadius: 4
}
]
};
};
const buildAudienceSegmentationChartData = (segmentation) => {
if (!segmentation || !segmentation.ageGroups) return { ageGroupsChartData: null, gendersChartData: null };
// Build chart data for age groups (primary chart)
const ageLabels = segmentation.ageGroups.map((i) => i.label);
const ageValues = segmentation.ageGroups.map((i) => parseFloat(i.value));
const ageGroupsChartData = {
labels: ageLabels,
datasets: [
{
data: ageValues,
backgroundColor: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'],
borderWidth: 0
}
]
};
// Keep genders chart for backward compatibility if needed
const gendersChartData =
Array.isArray(segmentation.genders) && segmentation.genders.length > 0
? {
labels: segmentation.genders.map((g) => g?.label || ''),
datasets: [
{
data: segmentation.genders.map((g) => {
const value = g?.value;
return value != null ? parseFloat(value) : 0;
}),
backgroundColor: segmentation.genders.map((_, idx) => ['#EC4899', '#3B82F6'][idx % 2]),
borderWidth: 0
}
]
}
: null;
return { ageGroupsChartData, gendersChartData };
};
// Build segments channel matrix data for heatmap
const buildSegmentsChannelMatrix = (matrix) => {
if (!Array.isArray(matrix) || matrix.length === 0) return null;
// Extract all unique channels from matrix
const channels = new Set();
matrix.forEach((row) => {
Object.keys(row).forEach((key) => {
if (key !== 'segmentName') {
channels.add(key);
}
});
});
return {
segments: matrix.map((row) => row.segmentName),
channels: Array.from(channels),
data: matrix
};
};
// Build market share chart data
const buildMarketShareChartData = (marketShareChart) => {
if (!Array.isArray(marketShareChart) || marketShareChart.length === 0) return null;
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#F97316', '#06B6D4'];
return {
labels: marketShareChart.map((item) => item.name),
datasets: [
{
data: marketShareChart.map((item) => item.value),
backgroundColor: marketShareChart.map((_, idx) => colors[idx % colors.length]),
borderColor: '#fff',
borderWidth: 2
}
]
};
};
// Build competitors bar chart data for reach and followers
const buildCompetitorsBarChartData = (competitors, metricType = 'reach') => {
if (!Array.isArray(competitors) || competitors.length === 0) return null;
return {
labels: competitors.map((comp) => comp.name),
datasets: [
{
label: metricType === 'reach' ? 'Охват аудитории' : 'Подписчики',
// Check nested structure digitalMetrics!
data: competitors.map((comp) => (comp.digitalMetrics ? comp.digitalMetrics[metricType] || 0 : comp[metricType] || 0)),
backgroundColor: metricType === 'reach' ? '#6366F1' : '#3B82F6', // Purple for reach, blue for followers
borderRadius: 4
}
]
};
};
// Build competitors heatmap data
const buildCompetitorsHeatmapData = (competitors) => {
if (!Array.isArray(competitors) || competitors.length === 0) return null;
const channels = new Set();
competitors.forEach((comp) => {
if (comp.channelsHeatmap && typeof comp.channelsHeatmap === 'object') {
Object.keys(comp.channelsHeatmap).forEach((channel) => channels.add(channel));
}
});
return {
competitors: competitors.map((comp) => comp.name),
channels: Array.from(channels),
data: competitors.map((comp) => ({
name: comp.name,
channelsHeatmap: comp.channelsHeatmap || {}
}))
};
};
// Get heatmap color intensity based on value (0-100)
const getHeatmapColor = (value) => {
if (value >= 80) return 'rgba(16, 185, 129, 0.9)'; // green-500
if (value >= 60) return 'rgba(245, 158, 11, 0.9)'; // amber-500
if (value >= 40) return 'rgba(251, 191, 36, 0.9)'; // yellow-500
if (value >= 20) return 'rgba(249, 115, 22, 0.9)'; // orange-500
return 'rgba(239, 68, 68, 0.9)'; // red-500
};
// Get channel matrix color based on value (high/medium/low)
const getChannelMatrixColor = (value) => {
if (!value) return 'rgba(156, 163, 175, 0.5)'; // gray
const normalizedValue = String(value).toLowerCase();
if (normalizedValue === 'high' || normalizedValue === 'высокий' || normalizedValue === 'высокая') return 'rgba(16, 185, 129, 0.9)'; // green
if (normalizedValue === 'medium' || normalizedValue === 'средний' || normalizedValue === 'средняя') return 'rgba(245, 158, 11, 0.9)'; // amber
if (normalizedValue === 'low' || normalizedValue === 'низкий' || normalizedValue === 'низкая') return 'rgba(239, 68, 68, 0.9)'; // red
return 'rgba(156, 163, 175, 0.5)'; // gray
};
// Helper: detect Chart.js "data" shape (labels + datasets)
const isChartJsData = (value) => {
return !!(value && typeof value === 'object' && !Array.isArray(value) && Array.isArray(value.labels) && Array.isArray(value.datasets));
};
// Helper: normalize Chart.js datasets to have sane colors
const normalizeChartJsData = (raw) => {
if (!isChartJsData(raw)) return null;
const colors = ['#6366F1', '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'];
return {
labels: raw.labels,
datasets: raw.datasets.map((dataset, idx) => {
const fallback = colors[idx % colors.length];
const bg = dataset.backgroundColor || dataset.borderColor || fallback;
return {
...dataset,
backgroundColor: dataset.backgroundColor || bg,
borderColor: dataset.borderColor || bg,
borderRadius: dataset.borderRadius ?? 4
};
})
};
};
// Main function to map chartsData to segments
const mapChartsDataToSegments = (chartsData) => {
if (!chartsData || typeof chartsData !== 'object') return [];
const segments = [];
// Handle case when chartsData is an array (shouldn't happen, but handle it)
if (Array.isArray(chartsData)) {
// If it's an array of competitors, process it
if (chartsData.length > 0 && chartsData[0]?.name && (chartsData[0]?.channelsHeatmap || chartsData[0]?.reach || chartsData[0]?.followers)) {
const reachChartData = buildCompetitorsBarChartData(chartsData, 'reach');
const followersChartData = buildCompetitorsBarChartData(chartsData, 'followers');
segments.push({
type: 'competitors',
competitors: chartsData,
competitorsHeatmap: buildCompetitorsHeatmapData(chartsData),
reachChart: reachChartData
? {
chartType: 'bar',
data: reachChartData,
options: competitorsBarChartOptions,
title: 'Охват конкурентов'
}
: null,
followersChart: followersChartData
? {
chartType: 'bar',
data: followersChartData,
options: competitorsBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
});
}
return segments;
}
// Handle case when chartsData is an audienceSegmentation object directly
// (has genders, ageGroups, segments, etc. but no audienceSegmentation property)
if (chartsData.genders || chartsData.ageGroups || chartsData.segments) {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData);
if (ageGroupsChartData || gendersChartData) {
segments.push({
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData,
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix),
keyTakeaways: chartsData.keyTakeaways || []
});
// Return early to avoid processing it again as audienceSegmentation property
// But continue to check for other properties like competitors
}
}
// 1. Audience Segmentation (nested property)
if (chartsData.audienceSegmentation) {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation);
if (ageGroupsChartData || gendersChartData) {
segments.push({
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData,
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.audienceSegmentation.segmentsChannelMatrix),
keyTakeaways: chartsData.audienceSegmentation.keyTakeaways || []
});
}
}
// 2. Market Share Chart (in root of chartsData)
if (chartsData.marketShareChart) {
const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart);
if (marketShareChartData) {
segments.push({
type: 'marketShareChart',
title: 'Доли рынка',
chartType: 'pie',
data: marketShareChartData,
options: pieChartOptions,
chartHeight: 'height: 300px'
});
}
}
// 3. Competitors Analysis
// Check if competitors is an array (new format) or object with competitors array (old format)
if (chartsData.competitors) {
let competitorsArray = null;
if (Array.isArray(chartsData.competitors)) {
// New format: competitors is directly an array
competitorsArray = chartsData.competitors;
} else if (chartsData.competitors.competitors && Array.isArray(chartsData.competitors.competitors)) {
// Old format: competitors is an object with competitors array inside
competitorsArray = chartsData.competitors.competitors;
}
if (competitorsArray && competitorsArray.length > 0) {
// Build bar charts for reach and followers using flat structure
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
segments.push({
type: 'competitors',
competitors: competitorsArray,
competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
reachChart: reachChartData
? {
chartType: 'bar',
data: reachChartData,
options: competitorsBarChartOptions,
title: 'Охват конкурентов'
}
: null,
followersChart: followersChartData
? {
chartType: 'bar',
data: followersChartData,
options: competitorsBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
});
}
// SWOT Competitors (if exists in competitors object)
if (chartsData.competitors.swotCompetitors && typeof chartsData.competitors.swotCompetitors === 'object') {
segments.push({
type: 'swotCompetitors',
data: {
strengths: chartsData.competitors.swotCompetitors.strengths || [],
weaknesses: chartsData.competitors.swotCompetitors.weaknesses || []
}
});
}
}
// 4. Comparison Table (in root of chartsData)
if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
segments.push({
type: 'comparisonTable',
data: chartsData.comparisonTable
});
}
// 5. SWOT Competitors (in root of chartsData, if exists)
if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') {
segments.push({
type: 'swotCompetitors',
data: {
strengths: chartsData.swotCompetitors.strengths || [],
weaknesses: chartsData.swotCompetitors.weaknesses || []
}
});
}
// 6. Seasonality
if (chartsData.seasonality && typeof chartsData.seasonality === 'object') {
const chartData = buildSeasonalityChartData(chartsData.seasonality);
if (chartData) {
segments.push({
type: 'chart',
key: 'seasonality',
title: 'Сезонность спроса',
chartType: 'line',
data: chartData,
options: seasonalityChartOptions,
chartHeight: 'height: 300px'
});
}
}
// 7. Market
if (chartsData.market && typeof chartsData.market === 'object') {
segments.push({
type: 'market',
data: chartsData.market
});
}
// 8. Conversion Funnel (if exists for backward compatibility)
if (chartsData.conversionFunnel && Array.isArray(chartsData.conversionFunnel)) {
const chartData = buildConversionFunnelChartData(chartsData.conversionFunnel);
if (chartData) {
segments.push({
type: 'chart',
key: 'funnel',
title: 'Воронка конверсии',
chartType: 'bar',
data: chartData,
options: conversionFunnelChartOptions,
chartHeight: 'height: 400px'
});
}
}
// 9. Channels Potential (if exists for backward compatibility)
if (chartsData.channelsPotential && Array.isArray(chartsData.channelsPotential)) {
const chartData = buildChannelsPotentialChartData(chartsData.channelsPotential);
if (chartData) {
segments.push({
type: 'chart',
key: 'channelsPotential',
title: 'Потенциал каналов продвижения',
chartType: 'bar',
data: chartData,
options: channelsPotentialChartOptions,
chartHeight: 'height: 300px',
channels: chartsData.channelsPotential
});
}
}
// 11. Audience charts (Chart.js config)
if (chartsData.audienceAge) {
const chartData = normalizeChartJsData(chartsData.audienceAge);
if (chartData) {
segments.push({
type: 'chart',
key: 'audienceAge',
title: 'Распределение по возрасту',
chartType: 'bar',
data: chartData,
options: competitorsBarChartOptions,
chartHeight: 'height: 350px'
});
}
}
if (chartsData.audienceGender) {
const chartData = normalizeChartJsData(chartsData.audienceGender);
if (chartData) {
segments.push({
type: 'chart',
key: 'audienceGender',
title: 'Распределение по полу',
chartType: 'pie',
data: chartData,
options: pieChartOptions,
chartHeight: 'height: 350px'
});
}
}
// 10. SWOT (if exists for backward compatibility)
if (chartsData.swot && typeof chartsData.swot === 'object') {
segments.push({
type: 'swot',
data: {
strengths: chartsData.swot.strengths || [],
weaknesses: chartsData.swot.weaknesses || [],
opportunities: chartsData.swot.opportunities || [],
threats: chartsData.swot.threats || []
}
});
}
return segments;
};
const mapJsonBlockToSegment = (identifier, data, rawText) => {
const key = identifier?.toLowerCase();
switch (key) {
case 'audienceage': {
const chartData = normalizeChartJsData(data);
if (!chartData) return null;
return {
type: 'chart',
key: 'audienceAge',
title: 'Распределение по возрасту',
chartType: 'bar',
data: chartData,
options: competitorsBarChartOptions,
chartHeight: 'height: 350px'
};
}
case 'audiencegender': {
const chartData = normalizeChartJsData(data);
if (!chartData) return null;
return {
type: 'chart',
key: 'audienceGender',
title: 'Распределение по полу',
chartType: 'pie',
data: chartData,
options: pieChartOptions,
chartHeight: 'height: 350px'
};
}
case 'seasonality': {
const chartData = isChartJsData(data) ? normalizeChartJsData(data) : buildSeasonalityChartData(data);
if (!chartData) return null;
return {
type: 'chart',
key: 'seasonality',
title: 'Сезонность спроса',
chartType: 'line',
data: chartData,
options: seasonalityChartOptions,
chartHeight: 'height: 300px'
};
}
case 'funnel':
case 'conversionfunnel': {
const chartData = isChartJsData(data) ? normalizeChartJsData(data) : buildConversionFunnelChartData(data);
if (!chartData) return null;
return {
type: 'chart',
key: 'funnel',
title: 'Воронка конверсии',
chartType: 'bar',
data: chartData,
options: conversionFunnelChartOptions,
chartHeight: 'height: 400px'
};
}
case 'channelspotential': {
const chartData = isChartJsData(data) ? normalizeChartJsData(data) : buildChannelsPotentialChartData(data);
if (!chartData) return null;
return {
type: 'chart',
key: 'channelsPotential',
title: 'Потенциал каналов продвижения',
chartType: 'bar',
data: chartData,
options: channelsPotentialChartOptions,
chartHeight: 'height: 300px',
channels: Array.isArray(data) ? data : []
};
}
case 'marketsharechart': {
const chartData = isChartJsData(data) ? normalizeChartJsData(data) : null;
if (!chartData) return null;
return {
type: 'marketShareChart',
title: 'Доли рынка',
chartType: 'pie',
data: chartData,
options: pieChartOptions,
chartHeight: 'height: 300px'
};
}
case 'comparisontable': {
if (!Array.isArray(data) || data.length === 0) return null;
return {
type: 'comparisonTable',
data
};
}
case 'audiencesegmentation': {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(data || {});
if (!ageGroupsChartData && !gendersChartData) return null;
return {
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData
};
}
case 'competitors': {
if (!Array.isArray(data) || data.length === 0) return null;
const reachChartData = buildCompetitorsBarChartData(data, 'reach');
const followersChartData = buildCompetitorsBarChartData(data, 'followers');
return {
type: 'competitors',
competitors: data,
competitorsHeatmap: buildCompetitorsHeatmapData(data),
reachChart: reachChartData
? {
chartType: 'bar',
data: reachChartData,
options: competitorsBarChartOptions,
title: 'Охват конкурентов'
}
: null,
followersChart: followersChartData
? {
chartType: 'bar',
data: followersChartData,
options: competitorsBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
};
}
case 'swot': {
if (!data || typeof data !== 'object') return null;
return {
type: 'swot',
data: {
strengths: data.strengths || [],
weaknesses: data.weaknesses || [],
opportunities: data.opportunities || [],
threats: data.threats || []
}
};
}
default:
// Unknown block type: render raw markdown so nothing is lost
return {
type: 'markdown',
html: renderMarkdown(rawText || '')
};
}
};
// Helper function to get chart segment by placeholder
const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
if (!chartsData) return null;
switch (placeholder) {
case 'CHART_AUDIENCE':
if (chartsData.audienceSegmentation) {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation);
if (ageGroupsChartData || gendersChartData) {
return {
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData,
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.audienceSegmentation.segmentsChannelMatrix),
keyTakeaways: chartsData.audienceSegmentation.keyTakeaways || []
};
}
}
return null;
case 'CHART_COMPETITORS':
const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
if (competitorsArray.length > 0) {
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
const segment = {
type: 'competitors',
competitors: competitorsArray,
competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
reachChart: reachChartData
? {
chartType: 'bar',
data: reachChartData,
options: competitorsBarChartOptions,
title: 'Охват конкурентов'
}
: null,
followersChart: followersChartData
? {
chartType: 'bar',
data: followersChartData,
options: competitorsBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
};
// Add marketShareChart if exists
if (chartsData.marketShareChart && Array.isArray(chartsData.marketShareChart)) {
const marketShareChartData = buildMarketShareChartData(chartsData.marketShareChart);
if (marketShareChartData) {
segment.marketShareChart = {
type: 'marketShareChart',
title: 'Доли рынка',
chartType: 'pie',
data: marketShareChartData,
options: pieChartOptions,
chartHeight: 'height: 300px'
};
}
}
// Add comparisonTable if exists
if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
segment.comparisonTable = {
type: 'comparisonTable',
data: chartsData.comparisonTable
};
}
return segment;
}
return null;
case 'CHART_SWOT':
// Check for swotCompetitors first
if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') {
return {
type: 'swotCompetitors',
data: {
strengths: chartsData.swotCompetitors.strengths || [],
weaknesses: chartsData.swotCompetitors.weaknesses || []
}
};
}
// Fallback to swot
if (chartsData.swot && typeof chartsData.swot === 'object') {
return {
type: 'swot',
data: {
strengths: chartsData.swot.strengths || [],
weaknesses: chartsData.swot.weaknesses || [],
opportunities: chartsData.swot.opportunities || [],
threats: chartsData.swot.threats || []
}
};
}
return null;
case 'CHART_CHANNELS':
if (chartsData.channelsPotential && Array.isArray(chartsData.channelsPotential)) {
const chartData = buildChannelsPotentialChartData(chartsData.channelsPotential);
if (chartData) {
return {
type: 'chart',
key: 'channelsPotential',
title: 'Потенциал каналов продвижения',
chartType: 'bar',
data: chartData,
options: channelsPotentialChartOptions,
chartHeight: 'height: 300px',
channels: chartsData.channelsPotential
};
}
}
return null;
case 'CHART_FUNNEL':
if (chartsData.conversionFunnel && Array.isArray(chartsData.conversionFunnel)) {
const chartData = buildConversionFunnelChartData(chartsData.conversionFunnel);
if (chartData) {
return {
type: 'chart',
key: 'funnel',
title: 'Воронка конверсии',
chartType: 'bar',
data: chartData,
options: conversionFunnelChartOptions,
chartHeight: 'height: 400px'
};
}
}
return null;
default:
return null;
}
};
// Helper function to get chart segment by JSON block identifier
const getChartSegmentByIdentifier = (identifier, chartsData) => {
if (!chartsData) return null;
const key = identifier?.toLowerCase();
switch (key) {
case 'audienceage': {
const chartData = normalizeChartJsData(chartsData.audienceAge);
if (!chartData) return null;
return {
type: 'chart',
key: 'audienceAge',
title: 'Распределение по возрасту',
chartType: 'bar',
data: chartData,
options: competitorsBarChartOptions,
chartHeight: 'height: 350px'
};
}
case 'audiencegender': {
const chartData = normalizeChartJsData(chartsData.audienceGender);
if (!chartData) return null;
return {
type: 'chart',
key: 'audienceGender',
title: 'Распределение по полу',
chartType: 'pie',
data: chartData,
options: pieChartOptions,
chartHeight: 'height: 350px'
};
}
case 'audiencesegmentation':
if (chartsData.audienceSegmentation) {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation);
if (ageGroupsChartData || gendersChartData) {
return {
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData,
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.audienceSegmentation.segmentsChannelMatrix),
keyTakeaways: chartsData.audienceSegmentation.keyTakeaways || []
};
}
}
return null;
case 'competitors':
const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
if (competitorsArray.length > 0) {
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
const segment = {
type: 'competitors',
competitors: competitorsArray,
competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
reachChart: reachChartData
? {
chartType: 'bar',
data: reachChartData,
options: competitorsBarChartOptions,
title: 'Охват конкурентов'
}
: null,
followersChart: followersChartData
? {
chartType: 'bar',
data: followersChartData,
options: competitorsBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
};
// Add marketShareChart if exists
if (chartsData.marketShareChart && Array.isArray(chartsData.marketShareChart)) {
const marketShareChartData = buildMarketShareChartData(chartsData.marketShareChart);
if (marketShareChartData) {
segment.marketShareChart = {
type: 'marketShareChart',
title: 'Доли рынка',
chartType: 'pie',
data: marketShareChartData,
options: pieChartOptions,
chartHeight: 'height: 300px'
};
}
}
// Add comparisonTable if exists
if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
segment.comparisonTable = {
type: 'comparisonTable',
data: chartsData.comparisonTable
};
}
return segment;
}
return null;
case 'swot':
// Check for swotCompetitors first
if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') {
return {
type: 'swotCompetitors',
data: {
strengths: chartsData.swotCompetitors.strengths || [],
weaknesses: chartsData.swotCompetitors.weaknesses || []
}
};
}
// Fallback to swot
if (chartsData.swot && typeof chartsData.swot === 'object') {
return {
type: 'swot',
data: {
strengths: chartsData.swot.strengths || [],
weaknesses: chartsData.swot.weaknesses || [],
opportunities: chartsData.swot.opportunities || [],
threats: chartsData.swot.threats || []
}
};
}
return null;
case 'marketsharechart': {
const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart);
if (!marketShareChartData) return null;
return {
type: 'marketShareChart',
title: 'Доли рынка',
chartType: 'pie',
data: marketShareChartData,
options: pieChartOptions,
chartHeight: 'height: 300px'
};
}
case 'comparisontable': {
if (Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
return {
type: 'comparisonTable',
data: chartsData.comparisonTable
};
}
return null;
}
case 'channelspotential':
case 'channels':
if (chartsData.channelsPotential && Array.isArray(chartsData.channelsPotential)) {
const chartData = buildChannelsPotentialChartData(chartsData.channelsPotential);
if (chartData) {
return {
type: 'chart',
key: 'channelsPotential',
title: 'Потенциал каналов продвижения',
chartType: 'bar',
data: chartData,
options: channelsPotentialChartOptions,
chartHeight: 'height: 300px',
channels: chartsData.channelsPotential
};
}
}
return null;
case 'funnel':
case 'conversionfunnel':
if (chartsData.conversionFunnel && Array.isArray(chartsData.conversionFunnel)) {
const chartData = buildConversionFunnelChartData(chartsData.conversionFunnel);
if (chartData) {
return {
type: 'chart',
key: 'funnel',
title: 'Воронка конверсии',
chartType: 'bar',
data: chartData,
options: conversionFunnelChartOptions,
chartHeight: 'height: 400px'
};
}
}
return null;
case 'seasonality':
if (chartsData.seasonality && typeof chartsData.seasonality === 'object') {
const chartData = buildSeasonalityChartData(chartsData.seasonality);
if (chartData) {
return {
type: 'chart',
key: 'seasonality',
title: 'Сезонность спроса',
chartType: 'line',
data: chartData,
options: seasonalityChartOptions,
chartHeight: 'height: 300px'
};
}
}
return null;
default:
return null;
}
};
// Helper function to check if text is a JSON object that should be processed
const tryParseJsonSegment = (text) => {
if (!text || typeof text !== 'string') return null;
let trimmed = text.trim();
// Remove HTML tags if present (from rendered markdown code blocks)
if (trimmed.includes('<pre>') || trimmed.includes('<code>') || trimmed.includes('<')) {
// Try to extract JSON from HTML code blocks - handle nested tags
let codeContent = trimmed;
// Remove all HTML tags recursively
while (codeContent.includes('<') && codeContent.includes('>')) {
codeContent = codeContent.replace(/<[^>]+>/g, '');
}
// Decode common HTML entities
codeContent = codeContent
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
trimmed = codeContent.trim();
}
// Also try to extract JSON if it's wrapped in HTML but not in code tags
// Look for JSON-like patterns in the text
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
// Try to find JSON object/array in the text
const jsonMatch = trimmed.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
if (jsonMatch) {
trimmed = jsonMatch[0].trim();
}
}
// Check if it's a JSON object or array
if ((!trimmed.startsWith('{') || !trimmed.endsWith('}')) && (!trimmed.startsWith('[') || !trimmed.endsWith(']'))) {
return null;
}
try {
const parsedJson = JSON.parse(trimmed);
// Check if it's an array of competitors
if (Array.isArray(parsedJson) && parsedJson.length > 0) {
// Check if it looks like competitors data
if (parsedJson[0]?.name && (parsedJson[0]?.channelsHeatmap || parsedJson[0]?.reach || parsedJson[0]?.followers)) {
return {
type: 'competitors',
competitors: parsedJson,
competitorsHeatmap: buildCompetitorsHeatmapData(parsedJson)
};
}
}
// Check if it's Chart.js format (datasets and labels)
if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
if (parsedJson.datasets && Array.isArray(parsedJson.datasets) && parsedJson.labels && Array.isArray(parsedJson.labels)) {
// Prepare chart data with proper formatting
const chartData = {
labels: parsedJson.labels,
datasets: parsedJson.datasets.map((dataset, index) => {
// Add default colors if not provided
const colors = ['#6366F1', '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'];
const color = dataset.backgroundColor || dataset.borderColor || colors[index % colors.length];
return {
...dataset,
backgroundColor: dataset.backgroundColor || color,
borderColor: dataset.borderColor || color,
borderRadius: dataset.borderRadius || 4
};
})
};
// Determine chart type based on dataset type or default to bar
const chartType = parsedJson.chartType || 'bar';
// Use appropriate options based on chart type
const options = chartType === 'pie' ? pieChartOptions : competitorsBarChartOptions;
// Get title from label or use default
const title = parsedJson.datasets[0]?.label || 'График';
return {
type: 'chart',
chartType: chartType,
data: chartData,
options: options,
title: title,
chartHeight: 'height: 400px'
};
}
}
// Check if it's an audienceSegmentation object
if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
if (parsedJson.genders || parsedJson.ageGroups || parsedJson.segments) {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(parsedJson);
if (ageGroupsChartData || gendersChartData) {
return {
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData,
segmentsChannelMatrix: buildSegmentsChannelMatrix(parsedJson.segmentsChannelMatrix),
keyTakeaways: parsedJson.keyTakeaways || []
};
}
}
}
} catch (e) {
// Not valid JSON
console.debug('Failed to parse JSON segment:', e);
}
return null;
};
// Parse fullAnalysis and replace placeholders and JSON blocks with charts from chartsData
const parseFullAnalysisWithCharts = (fullAnalysis, chartsData) => {
if (!fullAnalysis) return [];
const segments = [];
// Combined regex for both placeholders [[CHART_*]], JSON blocks ```json:identifier, and plain ```json blocks
const combinedRegex = /(\[\[CHART_(\w+)\]\]|```json:(\w+)\n([\s\S]*?)\n```|```json\n([\s\S]*?)\n```|```\n([\s\S]*?)\n```)/g;
let lastIndex = 0;
let match;
while ((match = combinedRegex.exec(fullAnalysis)) !== null) {
const [fullMatch, , chartTypePlaceholder, jsonIdentifier, jsonBlockWithId, jsonBlockPlain, codeBlock] = match;
// Add markdown before match
if (match.index > lastIndex) {
const textBefore = fullAnalysis.slice(lastIndex, match.index);
if (textBefore.trim()) {
// Extract JSON from markdown before rendering
const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(textBefore);
// Add extracted JSON segments
extractedSegments.forEach((seg) => segments.push(seg));
// Add remaining markdown (without JSON blocks)
if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
const renderedHtml = renderMarkdown(processedText);
// Process rendered HTML to extract JSON from code blocks
const { htmlSegments, chartSegments } = extractJsonFromRenderedHtml(renderedHtml);
// Add chart segments first
chartSegments.forEach((seg) => segments.push(seg));
// Add remaining HTML
if (htmlSegments.length > 0) {
htmlSegments.forEach((html) => segments.push({ type: 'markdown', html }));
} else if (renderedHtml.trim()) {
segments.push({ type: 'markdown', html: renderedHtml });
}
}
}
}
// Handle code blocks that might contain JSON
if (codeBlock || jsonBlockPlain) {
const jsonContent = codeBlock || jsonBlockPlain;
// Try to parse as JSON and process
const jsonSegment = tryParseJsonSegment(jsonContent);
if (jsonSegment) {
segments.push(jsonSegment);
} else {
// If not JSON, add as markdown code block
segments.push({ type: 'markdown', html: renderMarkdown(fullMatch) });
}
lastIndex = combinedRegex.lastIndex;
continue;
}
// Handle placeholder [[CHART_*]]
if (chartTypePlaceholder) {
const chartSegment = getChartSegmentByPlaceholder(chartTypePlaceholder, chartsData);
if (chartSegment) {
segments.push(chartSegment);
}
}
// Handle JSON block ```json:identifier
else if (jsonIdentifier) {
const chartSegment = getChartSegmentByIdentifier(jsonIdentifier, chartsData);
if (chartSegment) {
segments.push(chartSegment);
} else {
// If no chart found, try to parse JSON and use old logic as fallback
try {
const parsedData = JSON.parse(jsonBlockWithId);
const segment = mapJsonBlockToSegment(jsonIdentifier, parsedData, fullMatch);
if (segment) {
segments.push(segment);
} else {
// If segment is null, don't add anything - the JSON block will be ignored
// This prevents raw JSON from being displayed
console.warn(`Не удалось обработать JSON блок ${jsonIdentifier}`);
}
} catch (error) {
console.error(`Не удалось распарсить блок ${jsonIdentifier}:`, error);
// Don't add raw JSON block to segments - it will be ignored
}
}
}
lastIndex = combinedRegex.lastIndex;
}
// Add remaining markdown
const remaining = fullAnalysis.slice(lastIndex);
if (remaining.trim()) {
// Extract JSON from markdown before rendering
const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(remaining);
// Add extracted JSON segments
extractedSegments.forEach((seg) => segments.push(seg));
// Add remaining markdown (without JSON blocks)
if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
const renderedHtml = renderMarkdown(processedText);
// Process rendered HTML to extract JSON from code blocks
const { htmlSegments, chartSegments } = extractJsonFromRenderedHtml(renderedHtml);
// Add chart segments first
chartSegments.forEach((seg) => segments.push(seg));
// Add remaining HTML
if (htmlSegments.length > 0) {
htmlSegments.forEach((html) => segments.push({ type: 'markdown', html }));
} else if (renderedHtml.trim()) {
segments.push({ type: 'markdown', html: renderedHtml });
}
}
}
return segments;
};
// New computed property for chartsData
const chartsDataSegments = computed(() => {
if (!report.value?.chartsData) return [];
const chartsData = report.value.chartsData;
// Handle case when chartsData is an array directly (shouldn't happen, but handle it)
if (Array.isArray(chartsData)) {
// If it's an array of competitors, process it
if (chartsData.length > 0 && chartsData[0]?.name && (chartsData[0]?.channelsHeatmap || chartsData[0]?.reach || chartsData[0]?.followers)) {
const reachChartData = buildCompetitorsBarChartData(chartsData, 'reach');
const followersChartData = buildCompetitorsBarChartData(chartsData, 'followers');
return [
{
type: 'competitors',
competitors: chartsData,
competitorsHeatmap: buildCompetitorsHeatmapData(chartsData),
reachChart: reachChartData
? {
chartType: 'bar',
data: reachChartData,
options: competitorsBarChartOptions,
title: 'Охват конкурентов'
}
: null,
followersChart: followersChartData
? {
chartType: 'bar',
data: followersChartData,
options: competitorsBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
}
];
}
// If it's an array with audienceSegmentation-like structure, process it
if (chartsData.length > 0 && (chartsData[0]?.genders || chartsData[0]?.ageGroups)) {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData[0]);
if (ageGroupsChartData || gendersChartData) {
return [
{
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData,
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData[0].segmentsChannelMatrix),
keyTakeaways: chartsData[0].keyTakeaways || []
}
];
}
}
return [];
}
// Handle case when chartsData is an audienceSegmentation object directly
// (has genders, ageGroups, segments, etc. but no audienceSegmentation property)
if (chartsData.genders || chartsData.ageGroups || chartsData.segments) {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData);
if (ageGroupsChartData || gendersChartData) {
return [
{
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData,
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix),
keyTakeaways: chartsData.keyTakeaways || []
}
];
}
}
return mapChartsDataToSegments(chartsData);
});
// Main parsedContent that combines fullAnalysis with chartsData
const parsedContent = computed(() => {
let segments = [];
// If we have both fullAnalysis and chartsData, parse fullAnalysis and inject charts
if (report.value?.fullAnalysis && report.value?.chartsData) {
segments = parseFullAnalysisWithCharts(report.value.fullAnalysis, report.value.chartsData);
}
// If only chartsData exists, use it
else if (report.value?.chartsData) {
segments = chartsDataSegments.value;
}
// Otherwise, fall back to parsing fullAnalysis with JSON blocks (for old data)
else if (report.value?.fullAnalysis) {
const content = report.value.fullAnalysis;
// Regex for both ```json:identifier and plain ```json or ``` code blocks
const regex = /```json:(\w+)\n([\s\S]*?)\n```|```json\n([\s\S]*?)\n```|```\n([\s\S]*?)\n```/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(content)) !== null) {
const [fullMatch, identifier, jsonBlockWithId, jsonBlockPlain, codeBlock] = match;
if (match.index > lastIndex) {
const textBefore = content.slice(lastIndex, match.index);
if (textBefore.trim()) {
// Extract JSON from markdown before rendering
const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(textBefore);
// Add extracted JSON segments
extractedSegments.forEach((seg) => segments.push(seg));
// Add remaining markdown (without JSON blocks)
if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
segments.push({ type: 'markdown', html: renderMarkdown(processedText) });
}
}
}
// Handle code blocks that might contain JSON
if (codeBlock || jsonBlockPlain) {
const jsonContent = codeBlock || jsonBlockPlain;
// Try to parse as JSON and process
const jsonSegment = tryParseJsonSegment(jsonContent);
if (jsonSegment) {
segments.push(jsonSegment);
} else {
// If not JSON, add as markdown code block
segments.push({ type: 'markdown', html: renderMarkdown(fullMatch) });
}
lastIndex = regex.lastIndex;
continue;
}
// Handle ```json:identifier blocks
if (identifier && jsonBlockWithId) {
let parsedData = null;
try {
parsedData = JSON.parse(jsonBlockWithId);
} catch (error) {
console.error(`Не удалось распарсить блок ${identifier}:`, error);
}
const segment = mapJsonBlockToSegment(identifier, parsedData, fullMatch);
if (segment) {
segments.push(segment);
}
}
lastIndex = regex.lastIndex;
}
const remaining = content.slice(lastIndex);
if (remaining.trim()) {
// Extract JSON from markdown before rendering
const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(remaining);
// Add extracted JSON segments
extractedSegments.forEach((seg) => segments.push(seg));
// Add remaining markdown (without JSON blocks)
if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
segments.push({ type: 'markdown', html: renderMarkdown(processedText) });
}
}
}
// Filter out any segments without a valid type and ensure all segments are properly formatted
return segments.filter((segment) => {
// If segment doesn't have a type, try to process it
if (!segment || !segment.type) {
// If it's an array (like competitors), process it
if (Array.isArray(segment) && segment.length > 0 && segment[0]?.name) {
const processed = {
type: 'competitors',
competitors: segment,
competitorsHeatmap: buildCompetitorsHeatmapData(segment)
};
segments[segments.indexOf(segment)] = processed;
return true;
}
// If it's an object with audienceSegmentation-like structure, process it
if (segment && typeof segment === 'object' && (segment.genders || segment.ageGroups || segment.segments)) {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(segment);
if (ageGroupsChartData || gendersChartData) {
const processed = {
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData,
segmentsChannelMatrix: buildSegmentsChannelMatrix(segment.segmentsChannelMatrix),
keyTakeaways: segment.keyTakeaways || []
};
segments[segments.indexOf(segment)] = processed;
return true;
}
}
return false;
}
return true;
});
});
// Layout segments: group adjacent "paragraph markdown" + (chart/marketShareChart/comparisonTable) into a two-column container.
const isParagraphMarkdownSegment = (segment) => {
return segment?.type === 'markdown' && typeof segment.html === 'string' && /<p[\s>]/i.test(segment.html);
};
const isPairableMediaSegment = (segment) => {
return segment?.type === 'chart' || segment?.type === 'marketShareChart' || segment?.type === 'comparisonTable';
};
const displayContent = computed(() => {
const input = parsedContent.value || [];
const out = [];
for (let i = 0; i < input.length; i++) {
const cur = input[i];
const next = input[i + 1];
// Text then media
if (isParagraphMarkdownSegment(cur) && isPairableMediaSegment(next)) {
out.push({
type: 'paired',
text: cur,
media: next,
// on desktop we still keep text left; on mobile we can keep original (text first)
mobileOrder: 'text-first'
});
i++; // skip next
continue;
}
// Media then text
if (isPairableMediaSegment(cur) && isParagraphMarkdownSegment(next)) {
out.push({
type: 'paired',
text: next,
media: cur,
// keep original order on mobile (chart first)
mobileOrder: 'chart-first'
});
i++; // skip next
continue;
}
out.push(cur);
}
return out;
});
const toCompactChartOptions = (options) => {
const base = options && typeof options === 'object' ? options : {};
const basePlugins = base.plugins && typeof base.plugins === 'object' ? base.plugins : {};
const baseLegend = basePlugins.legend && typeof basePlugins.legend === 'object' ? basePlugins.legend : {};
const baseLegendLabels = baseLegend.labels && typeof baseLegend.labels === 'object' ? baseLegend.labels : {};
// Keep "legend.display: false" if it was explicitly turned off
const legendDisplay = typeof baseLegend.display === 'boolean' ? baseLegend.display : true;
const compact = {
...base,
responsive: true,
// For this request: keep aspect ratio behavior, but make it visually compact via aspectRatio + CSS max-height
maintainAspectRatio: true,
aspectRatio: typeof base.aspectRatio === 'number' ? base.aspectRatio : 3,
layout: {
...(base.layout && typeof base.layout === 'object' ? base.layout : {}),
padding: 0
},
plugins: {
...basePlugins,
legend: {
...baseLegend,
display: legendDisplay,
// Avoid legend eating vertical space in compact mode
position: baseLegend.position || 'bottom',
labels: {
...baseLegendLabels,
padding: 6,
boxWidth: 10,
boxHeight: 10,
usePointStyle: true,
font: {
...(baseLegendLabels.font && typeof baseLegendLabels.font === 'object' ? baseLegendLabels.font : {}),
size: 10
}
}
}
}
};
// Compact axis labels/ticks if scales exist
if (compact.scales && typeof compact.scales === 'object') {
const nextScales = { ...compact.scales };
for (const axisKey of Object.keys(nextScales)) {
const axis = nextScales[axisKey];
if (!axis || typeof axis !== 'object') continue;
const ticks = axis.ticks && typeof axis.ticks === 'object' ? axis.ticks : {};
const title = axis.title && typeof axis.title === 'object' ? axis.title : {};
nextScales[axisKey] = {
...axis,
ticks: {
...ticks,
autoSkip: typeof ticks.autoSkip === 'boolean' ? ticks.autoSkip : true,
maxRotation: typeof ticks.maxRotation === 'number' ? ticks.maxRotation : 0,
padding: 4,
font: {
...(ticks.font && typeof ticks.font === 'object' ? ticks.font : {}),
size: 10
}
},
title: {
...title,
padding: typeof title.padding !== 'undefined' ? title.padding : 4,
font: {
...(title.font && typeof title.font === 'object' ? title.font : {}),
size: 11
}
}
};
}
compact.scales = nextScales;
}
return compact;
};
const pieChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom'
},
tooltip: {
callbacks: {
label: function (context) {
return context.label + ': ' + context.parsed + '%';
}
}
}
}
};
// Get analysis type label
const getAnalysisTypeLabel = (type) => {
const labels = {
РЫНОК: 'Анализ внешней среды',
КОНКУРЕНТЫ: 'Анализ конкурентов',
ЦА: 'Анализ целевой аудитории',
КАНАЛЫ: 'Анализ рекламных каналов',
SWOT: 'SWOT-анализ'
};
return labels[type] || type;
};
// Format date time
const formatDateTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
date.setHours(date.getHours() + 5);
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Navigate to strategy page
const goToStrategy = () => {
if (analysisId.value) {
router.push({
name: 'marketing-promotion',
query: { analysisId: analysisId.value }
});
} else {
toast.add({
severity: 'warn',
summary: 'Предупреждение',
detail: 'ID анализа не найден',
life: 3000
});
}
};
// Load analysis from query params
onMounted(async () => {
const queryAnalysisId = route.query.analysisId;
if (queryAnalysisId) {
// Try to load existing analysis
try {
analysisId.value = queryAnalysisId;
const result = await MarketingService.getAnalysisResult(queryAnalysisId);
status.value = result.status;
// Сохраняем тип анализа, если он есть
if (result.analysisType) {
currentAnalysisType.value = result.analysisType;
}
if (result.status === 'completed' && result.report) {
report.value = result.report;
} else if (result.status === 'processing' || result.status === 'queued') {
estimatedCompletionTime.value = result.estimatedCompletionTime;
startPolling(queryAnalysisId);
}
} catch (error) {
console.error('Ошибка при загрузке анализа:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось загрузить анализ',
life: 5000
});
}
}
});
// Cleanup on unmount
onBeforeUnmount(() => {
stopPolling();
});
</script>
<style scoped>
.marketing-analysis-page {
font-family: var(--font-family);
}
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
padding: 1.5rem;
}
.card-header {
border-bottom: 1px solid var(--surface-border);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.card-content {
padding-top: 0;
}
.markdown-content {
line-height: 1.7;
color: var(--text-color);
font-size: 0.95rem;
}
.markdown-content :deep(h1) {
font-size: 1.75rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 1rem;
color: var(--text-color);
line-height: 1.3;
}
.markdown-content :deep(h1:first-child) {
margin-top: 0;
}
.markdown-content :deep(h2) {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--text-color);
line-height: 1.3;
}
.markdown-content :deep(h3) {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
color: var(--text-color);
line-height: 1.4;
}
.markdown-content :deep(h4) {
font-size: 1.1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--text-color);
line-height: 1.4;
}
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
font-size: 1rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.5rem;
color: var(--text-color);
line-height: 1.4;
}
.markdown-content :deep(p) {
margin-bottom: 1rem;
color: var(--text-color);
line-height: 1.7;
}
.markdown-content :deep(p:last-child) {
margin-bottom: 0;
}
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
margin-top: 0.5rem;
margin-bottom: 1rem;
padding-left: 2rem;
color: var(--text-color);
}
.markdown-content :deep(ul) {
list-style-type: disc;
}
.markdown-content :deep(ol) {
list-style-type: decimal;
}
.markdown-content :deep(li) {
margin-bottom: 0.5rem;
line-height: 1.7;
color: var(--text-color);
}
.markdown-content :deep(li > p) {
margin-bottom: 0.5rem;
}
.markdown-content :deep(li > ul),
.markdown-content :deep(li > ol) {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.markdown-content :deep(strong) {
font-weight: 600;
color: var(--text-color);
}
.markdown-content :deep(em) {
font-style: italic;
}
.markdown-content :deep(code) {
background-color: var(--surface-100);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875em;
font-family: 'Courier New', monospace;
}
.markdown-content :deep(pre) {
background-color: var(--surface-100);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.markdown-content :deep(pre code) {
background-color: transparent;
padding: 0;
}
.markdown-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--surface-border);
border-radius: 0.5rem;
overflow: hidden;
}
.markdown-content :deep(thead) {
background-color: var(--surface-100);
}
.markdown-content :deep(th) {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
color: var(--text-color);
border-bottom: 2px solid var(--surface-border);
}
.markdown-content :deep(td) {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
color: var(--text-color);
}
.markdown-content :deep(tr:last-child td) {
border-bottom: none;
}
.markdown-content :deep(tr:hover) {
background-color: var(--surface-50);
}
.report-pair {
/* Use text wrapping around the chart to avoid empty whitespace under the chart */
display: block;
}
.report-pair__text,
.report-pair__media {
min-width: 0;
}
/* .report-pair__text: text wraps around floated media (no extra styling needed) */
.report-pair__media {
float: right;
width: 40%;
max-width: 40%;
margin-left: 1.5rem; /* gap between text and chart */
margin-bottom: 0.75rem; /* let text wrap under after chart ends */
}
.report-pair__mediaChart {
width: 100%;
}
.report-pair__chart-container {
width: 100%;
/* Keep charts compact without forcing wrapper height */
max-height: 250px;
height: auto;
overflow: hidden;
}
/* clearfix for floated media */
.report-pair::after {
content: '';
display: block;
clear: both;
}
/* Remove top/bottom margins inside paired markdown so text starts flush with chart title/area */
.report-pair__text .markdown-content :deep(> *:first-child) {
margin-top: 0 !important;
}
.report-pair__text .markdown-content :deep(> *:last-child) {
margin-bottom: 0 !important;
}
/* Ensure Chart.js canvas doesn't add extra whitespace and respects container width */
.report-pair__chart-container :deep(canvas) {
width: 100% !important;
height: auto !important;
max-height: 250px;
display: block;
}
@media (max-width: 768px) {
.report-pair {
display: flex;
flex-direction: column;
}
/* Preserve original order when the report had chart before text */
.report-pair--mobile-chart-first .report-pair__media {
order: 1;
}
.report-pair--mobile-chart-first .report-pair__text {
order: 2;
}
.report-pair__media {
float: none;
width: 100%;
max-width: none;
margin-left: 0;
margin-bottom: 0.75rem;
}
/* Preserve original order on mobile */
.report-pair--mobile-text-first .report-pair__text {
order: 1;
}
.report-pair--mobile-text-first .report-pair__media {
order: 2;
}
.report-pair--mobile-chart-first .report-pair__media {
order: 1;
}
.report-pair--mobile-chart-first .report-pair__text {
order: 2;
}
}
.chart-container {
position: relative;
}
/* Channel Matrix Table Styles */
.channel-matrix-table {
border: 1px solid var(--surface-border);
border-radius: 0.5rem;
overflow: hidden;
}
.channel-matrix-th {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
color: var(--text-color);
background-color: var(--surface-100);
border-bottom: 2px solid var(--surface-border);
white-space: nowrap;
}
.channel-matrix-td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
color: var(--text-color);
}
.channel-matrix-tr:last-child .channel-matrix-td {
border-bottom: none;
}
.channel-matrix-tr:hover {
background-color: var(--surface-50);
}
.channel-matrix-badge {
display: inline-block;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
color: white;
font-weight: 600;
font-size: 0.875rem;
min-width: 70px;
text-align: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Competitors Heatmap Table Styles */
.competitors-heatmap-table {
border: 1px solid var(--surface-border);
border-radius: 0.5rem;
overflow: hidden;
}
.competitors-heatmap-th {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
color: var(--text-color);
background-color: var(--surface-100);
border-bottom: 2px solid var(--surface-border);
white-space: nowrap;
}
.competitors-heatmap-td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
color: var(--text-color);
}
.competitors-heatmap-tr:last-child .competitors-heatmap-td {
border-bottom: none;
}
.competitors-heatmap-tr:hover {
background-color: var(--surface-50);
}
.competitors-heatmap-badge {
display: inline-block;
padding: 0.5rem 0.875rem;
border-radius: 0.375rem;
color: white;
font-weight: 600;
font-size: 0.875rem;
min-width: 55px;
text-align: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Comparison Table Styles */
.comparison-table {
border: 1px solid var(--surface-border);
border-radius: 0.5rem;
overflow: hidden;
}
.comparison-table-th {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
color: var(--text-color);
background-color: var(--surface-100);
border-bottom: 2px solid var(--surface-border);
}
.comparison-table-td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
color: var(--text-color);
}
.comparison-table-tr:last-child .comparison-table-td {
border-bottom: none;
}
.comparison-table-tr:hover {
background-color: var(--surface-50);
}
</style>