diff --git a/src/views/pages/MarketingAnalysis.vue b/src/views/pages/MarketingAnalysis.vue index 15dc80f..aa7d184 100644 --- a/src/views/pages/MarketingAnalysis.vue +++ b/src/views/pages/MarketingAnalysis.vue @@ -453,7 +453,19 @@ >
- + +
+ +
+
+
+
+
+
{{ metric.label }}
+
+
{{ metric.value }}%
+
+
@@ -1229,6 +1241,23 @@ function parsePercent(value) { const ageRows = computed(() => { const a = report.value?.sections?.target_audience?.age_structure ?? {}; + // Маппинг ключей бэкенда на ключи фронтенда + // Если ключ бэкенда пересекается с несколькими группами, распределяем значение + const backendKeyMap = { + '18-24': { '18-24': 1.0 }, + '18-25': { '18-24': 1.0 }, // Маппим на 18-24 + '25-34': { '25-34': 1.0 }, + '25-45': { '25-34': 0.5, '35-44': 0.5 }, // Распределяем поровну + '35-44': { '35-44': 1.0 }, + '20-40': { '25-34': 0.5, '35-44': 0.5 }, // Распределяем поровну + '45-54': { '45-54': 1.0 }, + '40-60': { '45-54': 1.0 }, // Маппим на 45-54 + '55+': { '55+': 1.0 }, + '60+': { '55+': 1.0 }, + '60_plus': { '55+': 1.0 } + }; + + // Стандартный порядок для отображения const order = [ { key: '18-24', label: '18–24' }, { key: '25-34', label: '25–34' }, @@ -1237,9 +1266,28 @@ const ageRows = computed(() => { { key: '55+', label: '55+' } ]; + // Агрегируем значения из бэкенда + const aggregated = {}; + + Object.entries(a).forEach(([backendKey, value]) => { + const mapping = backendKeyMap[backendKey]; + if (mapping) { + const parsedValue = parsePercent(value); + if (Number.isFinite(parsedValue)) { + Object.entries(mapping).forEach(([frontendKey, ratio]) => { + aggregated[frontendKey] = (aggregated[frontendKey] || 0) + parsedValue * ratio; + }); + } + } + }); + return order.map((o) => { - const v = parsePercent(a?.[o.key]); - return { key: o.key, label: o.label, value: Number.isFinite(v) ? Math.round(v) : 0 }; + const v = aggregated[o.key] || 0; + return { + key: o.key, + label: o.label, + value: Number.isFinite(v) ? Math.round(v) : 0 + }; }); }); @@ -1260,6 +1308,75 @@ const compactDonutOptions = computed(() => ({ cutout: '62%' })); +// Опции для donut диаграммы с отображением процентов +const queryTypesDonutOptions = computed(() => { + return { + maintainAspectRatio: false, + plugins: { + legend: { position: 'right', labels: { usePointStyle: true, color: '#334155', boxWidth: 8 } }, + tooltip: { + callbacks: { + label: function (context) { + const label = context.label || ''; + const value = context.parsed || 0; + const total = context.dataset.data.reduce((a, b) => a + b, 0); + const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0; + return `${label}: ${percentage}%`; + } + } + }, + // Кастомный плагин для отображения процентов на сегментах + donutPercent: { + id: 'donutPercent', + afterDatasetsDraw(chart) { + const ctx = chart.ctx; + const data = chart.data.datasets[0]?.data || []; + const total = data.reduce((a, b) => a + b, 0); + + if (total === 0) return; + + chart.data.datasets.forEach((dataset, i) => { + const meta = chart.getDatasetMeta(i); + meta.data.forEach((element, index) => { + const value = dataset.data[index]; + const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0; + + // Пропускаем отображение, если процент слишком мал + if (parseFloat(percentage) < 3) return; + + // Получаем центр сегмента (для donut диаграммы используем центр дуги) + const arc = element; + const { x, y, startAngle, endAngle, innerRadius, outerRadius } = arc; + + // Вычисляем средний угол для размещения текста + const angle = (startAngle + endAngle) / 2; + // Размещаем текст на середине между innerRadius и outerRadius + const radius = (innerRadius + outerRadius) / 2; + + // Вычисляем координаты центра сегмента + const centerX = x + Math.cos(angle) * radius; + const centerY = y + Math.sin(angle) * radius; + + ctx.save(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 13px Inter, system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.shadowColor = 'rgba(0, 0, 0, 0.4)'; + ctx.shadowBlur = 4; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + ctx.fillText(`${percentage}%`, centerX, centerY); + ctx.restore(); + }); + }); + } + } + }, + cutout: '62%' + }; +}); + const ageBarData = computed(() => ({ labels: ageRows.value.map((r) => r.label), datasets: [ @@ -1495,6 +1612,22 @@ const queryTypesDonutData = computed(() => { }; }); +// Метрики для отображения под диаграммой +const queryTypesMetrics = computed(() => { + const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {}; + const labels = ['Прямой интерес', 'Поиск по вопросу', 'Конкуренты', 'Решение проблемы']; + const values = [q.direct_interest_catalog, q.question_based_articles, q.competitors_reviews, q.problem_solving_hot_promo].map(percentToNumber); + const colors = [PPT_BLUE, '#7C3AED', PPT_ORANGE, '#34D399']; + + return labels + .map((label, index) => ({ + label, + value: values[index] || 0, + color: colors[index] + })) + .filter((metric) => metric.value > 0); +}); + const decisionFactorsBarData = computed(() => { const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {}; const labels = ['Акция/скидка', 'Отзыв/рекоменд.', 'Гарантия/качество', 'Удобство/сервис', 'Наличие мест', 'Цена'];