3745 lines
183 KiB
Vue
3745 lines
183 KiB
Vue
<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="marketingBudget" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Бюджет на маркетинг </label>
|
||
<InputNumber id="marketingBudget" v-model="formData.marketingBudget" :min="0" placeholder="Введите бюджет в тенге" class="w-full" :class="{ 'p-invalid': errors.marketingBudget }" />
|
||
<small v-if="errors.marketingBudget" class="p-error">{{ errors.marketingBudget }}</small>
|
||
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Укажите ваш бюджет на маркетинг (опционально)</small>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="analysisType" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Тип анализа * </label>
|
||
<MultiSelect id="analysisType" v-model="formData.analysisType" :options="VALID_ANALYSIS_TYPES" placeholder="Выберите типы анализа" class="w-full" display="chip" :class="{ 'p-invalid': errors.analysisType }">
|
||
<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>
|
||
|
||
<!-- Единая страница: v2 dashboard сверху + старый анализ ниже -->
|
||
<div v-else class="space-y-4">
|
||
<!-- v2 Dashboard (показываем первым) -->
|
||
<div v-if="v2Loading || v2ReportNormalized" class="card">
|
||
<div class="card-content">
|
||
<MarketingAnalysisPptDashboard embedded :report="v2ReportNormalized" :loading="v2Loading" :error="v2Error" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Статус обработки (старый анализ) -->
|
||
<div v-if="status === 'processing' || status === 'queued'" class="space-y-4">
|
||
<!-- Заголовок с кнопкой -->
|
||
<div class="card">
|
||
<div class="card-header mb-4">
|
||
<div class="flex flex-wrap justify-content-between align-items-center gap-3">
|
||
<div class="flex-1 min-w-0">
|
||
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Анализ выполняется</h2>
|
||
</div>
|
||
<div class="flex gap-2 align-items-center flex-shrink-0">
|
||
<Button :label="isAnalysisOpen ? 'Скрыть анализ' : 'Показать анализ'" :icon="isAnalysisOpen ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" severity="secondary" outlined @click="isAnalysisOpen = !isAnalysisOpen" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-show="isAnalysisOpen" class="card">
|
||
<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>
|
||
|
||
<!-- Ошибка -->
|
||
<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 flex-wrap justify-content-between align-items-center gap-3">
|
||
<div class="flex-1 min-w-0">
|
||
<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>
|
||
<div class="flex gap-2 align-items-center flex-shrink-0">
|
||
<Button :label="isAnalysisOpen ? 'Скрыть анализ' : 'Показать анализ'" :icon="isAnalysisOpen ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" severity="secondary" outlined @click="isAnalysisOpen = !isAnalysisOpen" />
|
||
<Button label="Скачать PDF" icon="pi pi-download" @click="handleDownloadPdf" :loading="downloadingPdf" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-show="isAnalysisOpen" class="space-y-4">
|
||
<!-- Краткое резюме (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">
|
||
<!-- IMPORTANT: preserve original markdown order in DOM -->
|
||
<template v-if="segment.order === 'text-media'">
|
||
<div class="report-pair__text">
|
||
<div class="report-pair__textStack">
|
||
<div v-for="(t, tIdx) in segment.texts || []" :key="tIdx" class="markdown-content" v-html="t.html"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="report-pair__media">
|
||
<div v-if="segment.media.type === 'chart'">
|
||
<div class="report-pair__mediaChart">
|
||
<div class="chart-container report-pair__chart-container">
|
||
<Chart v-if="hasRenderableChartData(segment.media.data)" :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
|
||
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
|
||
</div>
|
||
<div v-if="segment.media.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.media.title }}</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="report-pair__mediaChart">
|
||
<div class="chart-container report-pair__chart-container">
|
||
<Chart v-if="hasRenderableChartData(segment.media.data)" :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
|
||
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
|
||
</div>
|
||
<div v-if="segment.media.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.media.title }}</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>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<div class="report-pair__media">
|
||
<div v-if="segment.media.type === 'chart'">
|
||
<div class="report-pair__mediaChart">
|
||
<div class="chart-container report-pair__chart-container">
|
||
<Chart v-if="hasRenderableChartData(segment.media.data)" :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
|
||
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
|
||
</div>
|
||
<div v-if="segment.media.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.media.title }}</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="report-pair__mediaChart">
|
||
<div class="chart-container report-pair__chart-container">
|
||
<Chart v-if="hasRenderableChartData(segment.media.data)" :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
|
||
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
|
||
</div>
|
||
<div v-if="segment.media.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.media.title }}</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="report-pair__textStack">
|
||
<div v-for="(t, tIdx) in segment.texts || []" :key="tIdx" class="markdown-content" v-html="t.html"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</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="chart-container" :style="segment.chartHeight">
|
||
<Chart v-if="hasRenderableChartData(segment.data)" :type="segment.chartType" :data="segment.data" :options="segment.options" />
|
||
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
|
||
</div>
|
||
<div v-if="segment.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.title }}</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) -->
|
||
<SegmentsChannelMatrixHeatmap v-if="segment.segmentsChannelMatrix" :matrix="segment.segmentsChannelMatrix" />
|
||
|
||
<!-- 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 === 'segmentsChannelMatrix'">
|
||
<HeatmapTable :matrix="segment.matrix" :title="segment.title || 'Матрица каналов по сегментам'" />
|
||
</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 v-if="hasRenderableChartData(segment.marketShareChart.data)" :type="segment.marketShareChart.chartType" :data="segment.marketShareChart.data" :options="segment.marketShareChart.options" />
|
||
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
|
||
</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 v-if="hasRenderableChartData(segment.data)" :type="segment.chartType" :data="segment.data" :options="segment.options" />
|
||
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
|
||
</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>
|
||
</div>
|
||
|
||
<!-- Кнопки действий (всегда видны, когда есть анализ) -->
|
||
<div v-if="(status === 'completed' && report) || v2ReportNormalized" class="card">
|
||
<div class="card-content text-center">
|
||
<div class="flex gap-3 justify-content-center flex-wrap">
|
||
<Button label="Создать стратегию на основе анализа" icon="pi pi-magic" severity="success" @click="goToStrategy" />
|
||
<Button label="Создать новый анализ" icon="pi pi-plus" severity="secondary" @click="resetAnalysis" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import SegmentsChannelMatrixHeatmap from '@/components/marketing/SegmentsChannelMatrixHeatmap.vue';
|
||
import MarketingService from '@/service/MarketingService';
|
||
import MarketingAnalysisPptDashboard from '@/views/pages/MarketingAnalysis.vue';
|
||
import { marked } from 'marked';
|
||
import markedKatex from 'marked-katex-extension';
|
||
import Button from 'primevue/button';
|
||
import Chart from 'primevue/chart';
|
||
import Checkbox from 'primevue/checkbox';
|
||
import InputNumber from 'primevue/inputnumber';
|
||
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();
|
||
|
||
let isKatexMarkedConfigured = false;
|
||
|
||
// 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: '',
|
||
marketingBudget: null,
|
||
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 isAnalysisOpen = ref(false);
|
||
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);
|
||
|
||
// v2 dashboard state (loaded via start?generateV2=true + GET /analysis/v2/{id})
|
||
const v2AnalysisId = ref(null);
|
||
const v2Report = ref(null); // raw v2 payload from GET /api/marketing/analysis/v2/{id}
|
||
const v2Loading = ref(false);
|
||
const v2Error = ref(null);
|
||
|
||
const isV2ReportPayload = (payload) => {
|
||
if (!payload || typeof payload !== 'object') return false;
|
||
if (payload.report_title || payload.reportTitle) return true;
|
||
if (payload.sections && typeof payload.sections === 'object') return true;
|
||
return false;
|
||
};
|
||
|
||
const applyV2Report = (payload) => {
|
||
v2Report.value = payload;
|
||
v2Loading.value = false;
|
||
v2Error.value = null;
|
||
};
|
||
|
||
const v2ReportNormalized = computed(() => {
|
||
const root = v2Report.value;
|
||
if (!root) return null;
|
||
|
||
const title = root.reportTitle || root.report_title || '';
|
||
const s = root.sections || {};
|
||
const pick = (camel, snake) => s[camel] || s[snake] || null;
|
||
|
||
return {
|
||
report_title: title,
|
||
sections: {
|
||
market_overview: pick('marketOverview', 'market_overview'),
|
||
target_audience: pick('targetAudience', 'target_audience'),
|
||
competitive_environment: pick('competitiveEnvironment', 'competitive_environment'),
|
||
demand_and_behavior: pick('demandAndBehavior', 'demand_and_behavior'),
|
||
customer_journey: pick('customerJourney', 'customer_journey'),
|
||
acquisition_channels: pick('acquisitionChannels', 'acquisition_channels'),
|
||
marketing_economics: pick('marketingEconomics', 'marketing_economics'),
|
||
bottlenecks: pick('bottlenecks', 'bottlenecks'),
|
||
recommendations: pick('recommendations', 'recommendations')
|
||
}
|
||
};
|
||
});
|
||
|
||
const drawBarValues = (chart) => {
|
||
const ctx = chart.ctx;
|
||
ctx.save();
|
||
ctx.font = 'bold 14px "Inter", sans-serif'; // Шрифт покрупнее, как на фото
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
|
||
chart.data.datasets.forEach((dataset, i) => {
|
||
const meta = chart.getDatasetMeta(i);
|
||
if (!meta.hidden) {
|
||
meta.data.forEach((bar, index) => {
|
||
const value = dataset.data[index];
|
||
if (value !== null && value !== undefined && value != 0) {
|
||
let label = value.toLocaleString();
|
||
// Если это проценты, добавляем знак %
|
||
if ((dataset.unit === '%') || (typeof dataset.data[index] === 'string' && String(dataset.data[index]).includes('%'))) {
|
||
label = String(value).replace('%', '') + '%';
|
||
}
|
||
|
||
const isHorizontal = meta.indexAxis === 'y';
|
||
|
||
if (isHorizontal) {
|
||
// Горизонтальный: цифра справа темная
|
||
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(label, bar.x + 8, bar.y);
|
||
} else {
|
||
// === ВЕРТИКАЛЬНЫЙ (КАК НА ФОТО) ===
|
||
const barHeight = Math.abs(bar.base - bar.y);
|
||
|
||
// Если столбец достаточно высокий (>25px), рисуем цифру ВНУТРИ белым цветом
|
||
if (barHeight > 25) {
|
||
ctx.fillStyle = '#ffffff';
|
||
// bar.y - это верхушка столбца. Добавляем 15px, чтобы спустить цифру чуть ниже верха
|
||
ctx.fillText(label, bar.x, bar.y + 15);
|
||
} else {
|
||
// Если столбец мелкий, рисуем цифру СВЕРХУ темным цветом
|
||
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937';
|
||
ctx.fillText(label, bar.x, bar.y - 12);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
ctx.restore();
|
||
};
|
||
|
||
// Опции: Дизайн "Как на фото" (С цифрами внутри)
|
||
const photoStyleBarOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
layout: { padding: { top: 25 } }, // Отступ сверху для тултипов
|
||
animation: {
|
||
duration: 500,
|
||
onComplete: (animation) => drawBarValues(animation.chart)
|
||
},
|
||
plugins: {
|
||
legend: { display: false }, // Легенда не нужна
|
||
tooltip: {
|
||
enabled: true,
|
||
callbacks: { label: (ctx) => `${ctx.formattedValue}%` }
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
display: true, // Ось Y оставляем (как на фото 0, 10, 20...), но можно поставить false, если надо скрыть
|
||
beginAtZero: true,
|
||
grid: { display: true, drawBorder: false, color: 'rgba(0,0,0,0.05)' },
|
||
ticks: { font: { size: 10 } }
|
||
},
|
||
x: {
|
||
grid: { display: false }, // Вертикальные линии убираем
|
||
ticks: {
|
||
font: { size: 12, weight: '600' },
|
||
color: '#64748b'
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Опции: Горизонтальные бары
|
||
const horizontalBarChartOptions = {
|
||
indexAxis: 'y',
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
layout: { padding: { right: 40 } },
|
||
animation: {
|
||
duration: 500,
|
||
onComplete: (animation) => drawBarValues(animation.chart)
|
||
},
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
x: { display: false, beginAtZero: true },
|
||
y: {
|
||
grid: { display: false },
|
||
ticks: { font: { size: 12, weight: '500' }, crossAlign: 'far' }
|
||
}
|
||
}
|
||
};
|
||
|
||
// Опции: Круговая диаграмма
|
||
const pieChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: true, position: 'bottom', align: 'center' },
|
||
tooltip: {
|
||
callbacks: { label: (context) => context.label + ': ' + context.parsed + '%' }
|
||
}
|
||
}
|
||
};
|
||
|
||
// Опции: Линейный график (Сезонность)
|
||
const seasonalityChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: true, position: 'bottom' } },
|
||
scales: { y: { beginAtZero: true } }
|
||
};
|
||
|
||
// Опции: Воронка
|
||
const conversionFunnelChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: true, position: 'bottom' } },
|
||
scales: {
|
||
y: { beginAtZero: true, title: { display: true, text: 'Пользователи' } }
|
||
}
|
||
};
|
||
|
||
// Опции: Потенциал каналов
|
||
const channelsPotentialChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: { y: { beginAtZero: true, max: 100 } }
|
||
};
|
||
|
||
// Хелпер для цветов
|
||
const MARKETING_COLORS = ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
|
||
const getMarketingColors = (count, offset = 0) => {
|
||
const safeCount = Math.max(0, Number(count) || 0);
|
||
if (safeCount === 0) return [];
|
||
return Array.from({ length: safeCount }, (_, i) => MARKETING_COLORS[(i + offset) % MARKETING_COLORS.length]);
|
||
};
|
||
|
||
const parsePercentageData = (obj) => {
|
||
if (!obj || typeof obj !== 'object') return { labels: [], values: [] };
|
||
const labels = Object.keys(obj).map(k => k.replace(/_/g, ' '));
|
||
const values = Object.values(obj).map(v => {
|
||
if (typeof v === 'string') return parseFloat(v.replace('%', ''));
|
||
return v;
|
||
});
|
||
return { labels, values };
|
||
};
|
||
|
||
const refreshV2 = async () => {
|
||
if (!v2AnalysisId.value) return;
|
||
v2Loading.value = true;
|
||
v2Error.value = null;
|
||
try {
|
||
const data = await MarketingService.getAnalysisV2ById(v2AnalysisId.value);
|
||
v2Report.value = data;
|
||
} catch (e) {
|
||
v2Error.value = e?.message || 'Не удалось загрузить v2 отчёт';
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'v2 недоступен',
|
||
detail: v2Error.value,
|
||
life: 4000
|
||
});
|
||
} finally {
|
||
v2Loading.value = false;
|
||
}
|
||
};
|
||
|
||
// Validation
|
||
const validateForm = () => {
|
||
errors.value = {};
|
||
|
||
// 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);
|
||
if (!result) return;
|
||
if (isV2ReportPayload(result)) {
|
||
stopPolling();
|
||
applyV2Report(result);
|
||
status.value = 'completed';
|
||
return;
|
||
}
|
||
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;
|
||
v2Report.value = null;
|
||
v2AnalysisId.value = null;
|
||
v2Error.value = null;
|
||
|
||
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();
|
||
}
|
||
if (formData.value.marketingBudget !== null && formData.value.marketingBudget !== undefined) {
|
||
requestData.marketing_budget = formData.value.marketingBudget;
|
||
}
|
||
|
||
const result = await MarketingService.startAnalysis(requestData, { generateV2: true });
|
||
|
||
// Обработка ответа: может быть массив или один объект
|
||
if (Array.isArray(result)) {
|
||
// Множественные анализы
|
||
const firstAnalysis = result[0];
|
||
analysisId.value = firstAnalysis.analysisId;
|
||
status.value = firstAnalysis.status;
|
||
estimatedCompletionTime.value = firstAnalysis.estimatedCompletion;
|
||
v2AnalysisId.value = firstAnalysis.v2AnalysisId || null;
|
||
router.replace({ query: { ...route.query, analysisId: analysisId.value, v2AnalysisId: v2AnalysisId.value || undefined } });
|
||
|
||
// Сохраняем типы анализов
|
||
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
|
||
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'Анализы запущены',
|
||
detail: `Создано ${result.length} анализов. Они будут обработаны параллельно.`,
|
||
life: 5000
|
||
});
|
||
|
||
// Запускаем polling для первого анализа (можно расширить для всех)
|
||
startPolling(firstAnalysis.analysisId);
|
||
|
||
// v2: загрузка сохраненного отчёта (если есть)
|
||
if (v2AnalysisId.value) {
|
||
await refreshV2();
|
||
}
|
||
} else {
|
||
// Один анализ
|
||
analysisId.value = result.analysisId;
|
||
status.value = result.status;
|
||
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
|
||
estimatedCompletionTime.value = result.estimatedCompletion;
|
||
v2AnalysisId.value = result.v2AnalysisId || null;
|
||
router.replace({ query: { ...route.query, analysisId: analysisId.value, v2AnalysisId: v2AnalysisId.value || undefined } });
|
||
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'Анализ запущен',
|
||
detail: result.message || 'Анализ успешно запущен. Результаты будут готовы в течение 5-10 минут.',
|
||
life: 5000
|
||
});
|
||
|
||
// Start polling
|
||
startPolling(result.analysisId);
|
||
|
||
// v2: загрузка сохраненного отчёта (если есть)
|
||
if (v2AnalysisId.value) {
|
||
await refreshV2();
|
||
}
|
||
}
|
||
} 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;
|
||
v2AnalysisId.value = null;
|
||
v2Report.value = null;
|
||
v2Loading.value = false;
|
||
v2Error.value = null;
|
||
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+))?)?\r?\n([\s\S]*?)\r?\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 {
|
||
// Support LaTeX blocks written as \[ ... \] by normalizing to $$ ... $$ (what KaTeX/marked extension expects).
|
||
// We apply this only outside fenced code blocks to avoid mangling JSON/code.
|
||
const normalizeLatexMathDelimiters = (input) => {
|
||
if (!input || typeof input !== 'string') return input;
|
||
|
||
// Split into "fenced code blocks" and "everything else".
|
||
// Fence pattern: ```lang?\n ... \n```
|
||
const fenceRe = /```[^\n]*\n[\s\S]*?\n```/g;
|
||
let out = '';
|
||
let lastIndex = 0;
|
||
let m;
|
||
|
||
const normalizeChunk = (chunk) => {
|
||
// Block math: \[ ... \] -> $$ ... $$
|
||
// Use non-greedy match across newlines.
|
||
let s = chunk.replace(/\\\[([\s\S]*?)\\\]/g, (_full, body) => `\n\n$$\n${body.trim()}\n$$\n\n`);
|
||
// Inline math: \( ... \) -> $ ... $ (optional but useful)
|
||
s = s.replace(/\\\(([\s\S]*?)\\\)/g, (_full, body) => `$${body.trim()}$`);
|
||
return s;
|
||
};
|
||
|
||
while ((m = fenceRe.exec(input)) !== null) {
|
||
const before = input.slice(lastIndex, m.index);
|
||
out += normalizeChunk(before);
|
||
out += m[0]; // keep fenced code blocks unchanged
|
||
lastIndex = m.index + m[0].length;
|
||
}
|
||
out += normalizeChunk(input.slice(lastIndex));
|
||
return out;
|
||
};
|
||
|
||
// Register KaTeX extension once (idempotent-ish in practice, but we guard to be safe).
|
||
// We avoid throwing on invalid LaTeX so markdown rendering never crashes the report.
|
||
if (!isKatexMarkedConfigured) {
|
||
marked.use(
|
||
markedKatex({
|
||
throwOnError: false,
|
||
strict: 'ignore'
|
||
})
|
||
);
|
||
isKatexMarkedConfigured = true;
|
||
}
|
||
|
||
// В marked v17 используется marked.parse() с опциями вторым параметром
|
||
const normalized = normalizeLatexMathDelimiters(markdown);
|
||
return marked.parse(normalized, { breaks: true, gfm: true });
|
||
} catch (error) {
|
||
console.error('Error rendering markdown:', error);
|
||
// В случае ошибки возвращаем исходный текст
|
||
return markdown;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Split markdown into block-level segments so charts/code blocks can be rendered strictly in-flow
|
||
* between the exact neighboring blocks (paragraphs, lists, headings, etc.).
|
||
*
|
||
* Important: we keep rendering via `renderMarkdown()` for compatibility, but use `marked.lexer()`
|
||
* to determine block boundaries and to mark token type for safer pairing logic.
|
||
*/
|
||
const pushMarkdownTokens = (segments, markdownChunk) => {
|
||
if (!markdownChunk || typeof markdownChunk !== 'string') return;
|
||
|
||
const trimmed = markdownChunk.trim();
|
||
if (!trimmed) return;
|
||
|
||
let tokens = [];
|
||
try {
|
||
tokens = marked.lexer(markdownChunk, { breaks: true, gfm: true }) || [];
|
||
} catch (e) {
|
||
// If lexer fails, fall back to a single segment (still in-flow, just less granular).
|
||
segments.push({ type: 'markdown', html: renderMarkdown(markdownChunk), mdTokenType: 'raw' });
|
||
return;
|
||
}
|
||
|
||
for (const token of tokens) {
|
||
if (!token || token.type === 'space') continue;
|
||
const raw = typeof token.raw === 'string' ? token.raw : '';
|
||
if (!raw.trim()) continue;
|
||
segments.push({
|
||
type: 'markdown',
|
||
html: renderMarkdown(raw),
|
||
mdTokenType: token.type
|
||
});
|
||
}
|
||
};
|
||
|
||
|
||
const buildSeasonalityChartData = (seasonality) => {
|
||
if (!seasonality || typeof seasonality !== 'object') return null;
|
||
|
||
// New backend format: Chart.js data shape { labels: [...], datasets: [...] }
|
||
if (isChartJsData(seasonality)) return normalizeChartJsData(seasonality, 'line');
|
||
|
||
// Chart.js-like (labels exist) but datasets missing/empty -> return safe shape so UI can show placeholder
|
||
if (Array.isArray(seasonality.labels)) {
|
||
return {
|
||
labels: seasonality.labels,
|
||
datasets: Array.isArray(seasonality.datasets) ? seasonality.datasets : []
|
||
};
|
||
}
|
||
|
||
// Legacy format: object map { "Январь": 80, ... }
|
||
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: getMarketingColors(funnel.length),
|
||
borderColor: '#ffffff',
|
||
borderWidth: 2,
|
||
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: getMarketingColors(ageValues.length),
|
||
borderColor: '#ffffff',
|
||
borderWidth: 2
|
||
}
|
||
]
|
||
};
|
||
|
||
// 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: getMarketingColors(segmentation.genders.length),
|
||
borderColor: '#ffffff',
|
||
borderWidth: 2
|
||
}
|
||
]
|
||
}
|
||
: 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;
|
||
return {
|
||
labels: marketShareChart.map((item) => item.name),
|
||
datasets: [
|
||
{
|
||
data: marketShareChart.map((item) => item.value),
|
||
backgroundColor: getMarketingColors(marketShareChart.length),
|
||
borderColor: '#ffffff',
|
||
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: getMarketingColors(competitors.length, metricType === 'reach' ? 0 : 1),
|
||
borderColor: '#ffffff',
|
||
borderWidth: 2,
|
||
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 || {}
|
||
}))
|
||
};
|
||
};
|
||
|
||
// --- ВСТАВИТЬ СЮДА ---
|
||
|
||
// Сборка данных для ДОХОДА (Фото 2)
|
||
const buildIncomeChartData = (targetAudience) => {
|
||
// Данные могут быть в snake_case или camelCase
|
||
const incomeData = targetAudience?.income_by_age || targetAudience?.incomeByAge;
|
||
if (!incomeData || typeof incomeData !== 'object') return null;
|
||
|
||
const labels = Object.keys(incomeData);
|
||
const values = Object.values(incomeData);
|
||
|
||
return {
|
||
labels: labels,
|
||
datasets: [
|
||
{
|
||
label: 'Средний доход (KZT)',
|
||
data: values,
|
||
backgroundColor: getMarketingColors(values.length),
|
||
borderRadius: 4
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
const buildChannelsChartData = (acquisitionChannels) => {
|
||
const shares = acquisitionChannels?.channel_shares || acquisitionChannels?.channelShares;
|
||
if (!shares || typeof shares !== 'object') return null;
|
||
|
||
// Превращаем ключи "instagram_target" в "Instagram Target"
|
||
const labels = Object.keys(shares).map(key =>
|
||
key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||
);
|
||
|
||
// Чистим значения от знака % для графика
|
||
const values = Object.values(shares).map(val =>
|
||
(typeof val === 'string') ? parseFloat(val.replace('%', '')) : val
|
||
);
|
||
|
||
return {
|
||
labels: labels,
|
||
datasets: [
|
||
{
|
||
label: 'Доля',
|
||
data: values,
|
||
unit: '%', // Маркер, чтобы рисовать %
|
||
backgroundColor: getMarketingColors(values.length, 2),
|
||
borderRadius: 6
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
// 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
|
||
};
|
||
|
||
// 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
|
||
// - For pie/doughnut/bar we default to per-item colors (arrays) + white borders
|
||
// - If backgroundColor is already an array or function, we keep it (custom colors)
|
||
const normalizeChartJsData = (raw, chartType) => {
|
||
if (!isChartJsData(raw)) return null;
|
||
const type = String(chartType || raw.chartType || '').toLowerCase();
|
||
const wantsPerItemColors = type === 'pie' || type === 'doughnut' || type === 'bar';
|
||
|
||
return {
|
||
labels: Array.isArray(raw.labels) ? raw.labels : [],
|
||
datasets: raw.datasets.map((dataset, idx) => {
|
||
const data = Array.isArray(dataset?.data) ? dataset.data : [];
|
||
|
||
const hasCustomBgArray = Array.isArray(dataset?.backgroundColor);
|
||
const hasCustomBgFn = typeof dataset?.backgroundColor === 'function';
|
||
|
||
const backgroundColor = wantsPerItemColors && !hasCustomBgArray && !hasCustomBgFn ? getMarketingColors(data.length, idx) : dataset?.backgroundColor;
|
||
|
||
// Keep existing borderColor if provided; otherwise apply white borders for separation on partitioned charts
|
||
const borderColor = dataset?.borderColor ?? (wantsPerItemColors ? '#ffffff' : undefined);
|
||
const borderWidth = dataset?.borderWidth ?? (wantsPerItemColors ? 2 : undefined);
|
||
|
||
// For non-partitioned charts, keep old behavior: if colors are missing, use a reasonable fallback
|
||
const fallbackColor = getMarketingColors(1, idx)[0];
|
||
const resolvedBg = typeof backgroundColor === 'undefined' ? dataset?.borderColor || fallbackColor : backgroundColor;
|
||
const resolvedBorder = typeof borderColor === 'undefined' ? dataset?.borderColor || resolvedBg : borderColor;
|
||
|
||
return {
|
||
...dataset,
|
||
data,
|
||
backgroundColor: resolvedBg,
|
||
borderColor: resolvedBorder,
|
||
...(typeof borderWidth !== 'undefined' ? { borderWidth } : {}),
|
||
borderRadius: dataset.borderRadius ?? 4
|
||
};
|
||
})
|
||
};
|
||
};
|
||
|
||
// Helper: check if chart has any renderable data (avoid crashing when datasets is missing/empty)
|
||
const hasRenderableChartData = (chartData) => {
|
||
if (!chartData || typeof chartData !== 'object') return false;
|
||
const datasets = Array.isArray(chartData.datasets) ? chartData.datasets : [];
|
||
if (datasets.length === 0) return false;
|
||
return datasets.some((ds) => Array.isArray(ds?.data) && ds.data.length > 0);
|
||
};
|
||
|
||
// Main function to map chartsData to segments
|
||
const mapChartsDataToSegments = (chartsData) => {
|
||
if (!chartsData || typeof chartsData !== 'object') return [];
|
||
|
||
const segments = [];
|
||
const sections = chartsData.sections || chartsData;
|
||
|
||
// --- 1. СПРОС И ПОВЕДЕНИЕ ---
|
||
const demandSection = sections.demand_and_behavior || sections.demandAndBehavior;
|
||
if (demandSection) {
|
||
// Причины отказов (Оранжевые, как на фото 1)
|
||
const refusals = demandSection.reasons_for_refusal || demandSection.reasonsForRefusal;
|
||
if (refusals) {
|
||
const { labels, values } = parsePercentageData(refusals);
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'refusalReasons',
|
||
title: 'Причины отказов',
|
||
chartType: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data: values,
|
||
backgroundColor: '#fb923c', // Оранжевый
|
||
borderRadius: 8,
|
||
barPercentage: 0.7,
|
||
unit: '%'
|
||
}]
|
||
},
|
||
options: photoStyleBarOptions, // <-- Стиль как на фото (цифры внутри)
|
||
chartHeight: 'height: 300px'
|
||
});
|
||
}
|
||
|
||
// Факторы принятия решения (Сиреневые, стиль фото 6)
|
||
const factors = demandSection.decision_factors || demandSection.decisionFactors;
|
||
if (factors) {
|
||
const { labels, values } = parsePercentageData(factors);
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'decisionFactors',
|
||
title: 'Факторы принятия решения',
|
||
chartType: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data: values,
|
||
backgroundColor: '#818cf8', // Сиреневый
|
||
borderRadius: 8,
|
||
barPercentage: 0.7,
|
||
unit: '%'
|
||
}]
|
||
},
|
||
options: photoStyleBarOptions,
|
||
chartHeight: 'height: 300px'
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- 2. ЦЕЛЕВАЯ АУДИТОРИЯ ---
|
||
const audienceSection = sections.target_audience || sections.targetAudience;
|
||
if (audienceSection) {
|
||
// ВОЗРАСТНАЯ СТРУКТУРА (ФОТО 3 - СИНИЙ ЦВЕТ)
|
||
const ageStruct = audienceSection.age_structure || audienceSection.ageStructure;
|
||
if (ageStruct) {
|
||
const { labels, values } = parsePercentageData(ageStruct);
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'ageStructure',
|
||
title: 'Возрастная структура',
|
||
chartType: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data: values,
|
||
// !!! ЦВЕТ КАК НА ФОТО (СИНИЙ) !!!
|
||
backgroundColor: '#60a5fa',
|
||
borderRadius: 8, // Скругление
|
||
barPercentage: 0.65,
|
||
unit: '%'
|
||
}]
|
||
},
|
||
options: photoStyleBarOptions, // Белые цифры внутри
|
||
chartHeight: 'height: 300px'
|
||
});
|
||
}
|
||
|
||
// Доход (Фото 5 - Мятный цвет)
|
||
const incomeData = audienceSection.income_by_age || audienceSection.incomeByAge;
|
||
if (incomeData || audienceSection.metrics?.above_average_income_share) {
|
||
let labels = [], values = [];
|
||
if (incomeData) {
|
||
const parsed = parsePercentageData(incomeData);
|
||
labels = parsed.labels;
|
||
values = parsed.values;
|
||
} else {
|
||
labels = ['Низкий', 'Средний', 'Высокий'];
|
||
values = [20, 50, 30];
|
||
}
|
||
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'incomeLevel',
|
||
title: 'Уровень дохода аудитории',
|
||
chartType: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data: values,
|
||
backgroundColor: '#34d399', // Мятный
|
||
borderRadius: 8,
|
||
barPercentage: 0.65,
|
||
unit: '%'
|
||
}]
|
||
},
|
||
options: photoStyleBarOptions,
|
||
chartHeight: 'height: 300px'
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- 3. КАНАЛЫ (Фото 4 - Разноцветные) ---
|
||
const acquisitionSection = sections.acquisition_channels || sections.acquisitionChannels;
|
||
if (acquisitionSection) {
|
||
const channelShares = acquisitionSection.channel_shares || acquisitionSection.channelShares;
|
||
if (channelShares) {
|
||
const { labels, values } = parsePercentageData(channelShares);
|
||
// Убираем нули
|
||
const filteredIndices = values.map((v, i) => v > 0 ? i : -1).filter(i => i !== -1);
|
||
const finalLabels = filteredIndices.map(i => labels[i]);
|
||
const finalValues = filteredIndices.map(i => values[i]);
|
||
|
||
if (finalValues.length > 0) {
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'acquisitionChannels',
|
||
title: 'Доли каналов привлечения',
|
||
chartType: 'bar',
|
||
data: {
|
||
labels: finalLabels,
|
||
datasets: [{
|
||
data: finalValues,
|
||
backgroundColor: getMarketingColors(finalValues.length), // Разноцветные
|
||
borderRadius: 8,
|
||
barPercentage: 0.6,
|
||
unit: '%'
|
||
}]
|
||
},
|
||
options: photoStyleBarOptions,
|
||
chartHeight: 'height: 300px'
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 4. СЕЗОННОСТЬ (Line) ---
|
||
const marketSection = sections.market_overview || sections.marketOverview;
|
||
if (marketSection) {
|
||
const seasonalData = marketSection.monthly_online_index || marketSection.monthlyOnlineIndex;
|
||
if (seasonalData) {
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'seasonality',
|
||
title: 'Динамика спроса',
|
||
chartType: 'line',
|
||
data: {
|
||
labels: Object.keys(seasonalData),
|
||
datasets: [{
|
||
label: 'Индекс',
|
||
data: Object.values(seasonalData),
|
||
borderColor: '#6366f1',
|
||
backgroundColor: 'rgba(99, 102, 241, 0.2)',
|
||
fill: true,
|
||
tension: 0.4
|
||
}]
|
||
},
|
||
options: seasonalityChartOptions,
|
||
chartHeight: 'height: 350px'
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- LEGACY (Старый код, просто чтобы не ломалось, если придет старый JSON) ---
|
||
if (Array.isArray(chartsData)) {
|
||
if (chartsData.length > 0 && chartsData[0]?.name) {
|
||
const reachData = chartsData.map(c => c.reach || 0);
|
||
return [{
|
||
type: 'chart',
|
||
title: 'Охват конкурентов',
|
||
chartType: 'bar',
|
||
data: { labels: chartsData.map(c => c.name), datasets: [{ data: reachData, backgroundColor: '#60a5fa' }] },
|
||
options: photoStyleBarOptions
|
||
}];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
return segments;
|
||
};
|
||
const mapJsonBlockToSegment = (identifier, data, rawText) => {
|
||
const key = identifier?.toLowerCase();
|
||
switch (key) {
|
||
case 'audienceage': {
|
||
const chartData = normalizeChartJsData(data, 'bar');
|
||
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, 'pie');
|
||
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, 'line') : 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, 'bar') : 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, 'bar') : 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, 'pie') : 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 || []
|
||
}
|
||
};
|
||
}
|
||
case 'segmentschannelmatrix': {
|
||
const matrix = buildSegmentsChannelMatrix(data);
|
||
if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) return null;
|
||
return {
|
||
type: 'segmentsChannelMatrix',
|
||
title: 'Матрица каналов по сегментам',
|
||
matrix
|
||
};
|
||
}
|
||
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, 'bar');
|
||
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, 'pie');
|
||
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, 'pie');
|
||
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) {
|
||
const chartData = isChartJsData(chartsData.channelsPotential) ? normalizeChartJsData(chartsData.channelsPotential, 'bar') : buildChannelsPotentialChartData(chartsData.channelsPotential);
|
||
if (chartData) {
|
||
return {
|
||
type: 'chart',
|
||
key: 'channelsPotential',
|
||
title: 'Потенциал каналов продвижения',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: channelsPotentialChartOptions,
|
||
chartHeight: 'height: 300px',
|
||
channels: Array.isArray(chartsData.channelsPotential) ? chartsData.channelsPotential : []
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
|
||
case 'funnel':
|
||
case 'conversionfunnel':
|
||
if (chartsData.conversionFunnel) {
|
||
const chartData = isChartJsData(chartsData.conversionFunnel) ? normalizeChartJsData(chartsData.conversionFunnel, 'bar') : 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(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/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)) {
|
||
// 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' || chartType === 'doughnut' ? pieChartOptions : chartType === 'line' ? seasonalityChartOptions : competitorsBarChartOptions;
|
||
|
||
// Normalize chart data (colors/borders, safety)
|
||
const chartData = normalizeChartJsData(parsedJson, chartType);
|
||
if (!chartData) return null;
|
||
|
||
// 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+)\r?\n([\s\S]*?)\r?\n```|```json\r?\n([\s\S]*?)\r?\n```|```\r?\n([\s\S]*?)\r?\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()) {
|
||
// IMPORTANT: keep markdown strictly in-flow.
|
||
// JSON/Chart blocks are handled by the main regex loop and must appear exactly where they are in fullAnalysis.
|
||
pushMarkdownTokens(segments, textBefore);
|
||
}
|
||
}
|
||
|
||
// 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), mdTokenType: 'code' });
|
||
}
|
||
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()) {
|
||
// Keep tail markdown in-flow; chart blocks already handled by regex matches above.
|
||
pushMarkdownTokens(segments, remaining);
|
||
}
|
||
|
||
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+)\r?\n([\s\S]*?)\r?\n```|```json\r?\n([\s\S]*?)\r?\n```|```\r?\n([\s\S]*?)\r?\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()) {
|
||
// Keep text strictly in place; JSON blocks are handled by the regex loop.
|
||
pushMarkdownTokens(segments, textBefore);
|
||
}
|
||
}
|
||
|
||
// 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), mdTokenType: 'code' });
|
||
}
|
||
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()) {
|
||
pushMarkdownTokens(segments, remaining);
|
||
}
|
||
}
|
||
|
||
// 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' && segment?.mdTokenType === 'paragraph';
|
||
};
|
||
|
||
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)) {
|
||
// Pull subsequent paragraph tokens into the same left column so text can continue
|
||
// alongside a tall right-column chart/table (no float, still strictly in-flow).
|
||
const texts = [cur];
|
||
let j = i + 2;
|
||
while (j < input.length && isParagraphMarkdownSegment(input[j])) {
|
||
texts.push(input[j]);
|
||
j++;
|
||
}
|
||
out.push({
|
||
type: 'paired',
|
||
texts,
|
||
media: next,
|
||
order: 'text-media'
|
||
});
|
||
i = j - 1; // consumed media + following paragraph(s)
|
||
continue;
|
||
}
|
||
|
||
// Media then text
|
||
if (isPairableMediaSegment(cur) && isParagraphMarkdownSegment(next)) {
|
||
// IMPORTANT: do NOT pull many paragraphs when media is on the left.
|
||
// Otherwise right column grows tall and leaves a visible blank area under left media (table/chart).
|
||
const texts = [next];
|
||
const j = i + 2;
|
||
out.push({
|
||
type: 'paired',
|
||
texts,
|
||
media: cur,
|
||
order: 'media-text'
|
||
});
|
||
i = j - 1; // consumed media + single adjacent paragraph
|
||
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: 'bottom',
|
||
align: 'center',
|
||
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;
|
||
};
|
||
|
||
// 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;
|
||
const queryV2AnalysisId = route.query.v2AnalysisId;
|
||
if (queryAnalysisId) {
|
||
// Try to load existing analysis
|
||
try {
|
||
analysisId.value = queryAnalysisId;
|
||
const result = await MarketingService.getAnalysisResult(queryAnalysisId);
|
||
const isV2 = isV2ReportPayload(result);
|
||
if (isV2) {
|
||
applyV2Report(result);
|
||
status.value = 'completed';
|
||
} else if (result) {
|
||
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);
|
||
}
|
||
|
||
// v2 restore:
|
||
// 1) from query param
|
||
// 2) fallback to /analysis/my lookup (docs say v2AnalysisId is present there)
|
||
if (queryV2AnalysisId) {
|
||
v2AnalysisId.value = String(queryV2AnalysisId);
|
||
await refreshV2();
|
||
} else {
|
||
try {
|
||
const list = await MarketingService.getMyAnalyses();
|
||
const found = Array.isArray(list) ? list.find((a) => a?.analysisId === queryAnalysisId) : null;
|
||
if (found?.v2AnalysisId) {
|
||
v2AnalysisId.value = found.v2AnalysisId;
|
||
router.replace({ query: { ...route.query, v2AnalysisId: v2AnalysisId.value } });
|
||
await refreshV2();
|
||
}
|
||
} catch (_) {
|
||
// ignore v2 restore errors; old analysis page must keep working
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка при загрузке анализа:', error);
|
||
toast.add({
|
||
severity: 'error',
|
||
summary: 'Ошибка',
|
||
detail: error.message || 'Не удалось загрузить анализ',
|
||
life: 5000
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Cleanup on unmount
|
||
onBeforeUnmount(() => {
|
||
stopPolling();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.v2-glow :deep(canvas) {
|
||
filter: drop-shadow(0 0 14px rgba(59, 130, 246, 0.22));
|
||
}
|
||
|
||
.marketing-analysis-page {
|
||
font-family: var(--font-family);
|
||
}
|
||
|
||
.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 {
|
||
/* Keep everything strictly in normal flow; no float/absolute positioning */
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(260px, 40%);
|
||
gap: 1.5rem;
|
||
align-items: start;
|
||
}
|
||
|
||
.report-pair__text,
|
||
.report-pair__media {
|
||
min-width: 0;
|
||
}
|
||
|
||
.report-pair__textStack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.report-pair__media {
|
||
width: 100%;
|
||
align-self: start;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
.chart-caption {
|
||
margin-top: 0.5rem;
|
||
text-align: center;
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
line-height: 1.25rem;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.report-pair {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.chart-container {
|
||
position: relative;
|
||
}
|
||
|
||
/* 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>
|