From ebef3d08bd0a719a9308d75e3b39af9f32929ef8 Mon Sep 17 00:00:00 2001 From: Arystanbek Date: Mon, 19 Jan 2026 12:06:50 +0000 Subject: [PATCH] Update MarketingAnalysis.vue --- .../pages/marketing/MarketingAnalysis.vue | 277 +++++++++++++++++- 1 file changed, 262 insertions(+), 15 deletions(-) diff --git a/src/views/pages/marketing/MarketingAnalysis.vue b/src/views/pages/marketing/MarketingAnalysis.vue index b18495a..311b297 100644 --- a/src/views/pages/marketing/MarketingAnalysis.vue +++ b/src/views/pages/marketing/MarketingAnalysis.vue @@ -932,6 +932,92 @@ const v2ReportNormalized = computed(() => { }; }); +const drawBarValues = (chart) => { + const ctx = chart.ctx; + ctx.font = 'bold 11px "Inter", sans-serif'; + ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + + 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 value === 'string' && value.includes('%'))) { + label = String(value).replace('%', '') + '%'; + } + + const isHorizontal = meta.indexAxis === 'y'; + + if (isHorizontal) { + // Для горизонтальных (Доход): рисуем справа + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, bar.x + 5, bar.y); + } else { + // Для вертикальных: рисуем сверху + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(label, bar.x, bar.y - 5); + } + } + }); + } + }); +}; + +const competitorsBarChartOptions = { + responsive: true, + maintainAspectRatio: false, + layout: { padding: { top: 20 } }, + animation: { + duration: 500, + onComplete: (animation) => drawBarValues(animation.chart) + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: true } + }, + scales: { + y: { + beginAtZero: true, + grid: { display: true, drawBorder: false }, + ticks: { display: false } // Скрываем ось Y, цифры уже на барах + }, + x: { grid: { display: false } } + } +}; + +const horizontalBarChartOptions = { + indexAxis: 'y', // <-- Делает график горизонтальным + responsive: true, + maintainAspectRatio: false, + layout: { padding: { right: 45 } }, + animation: { + duration: 500, + onComplete: (animation) => drawBarValues(animation.chart) + }, + plugins: { legend: { display: false } }, + scales: { + x: { display: false, beginAtZero: true }, // Скрываем ось X + y: { grid: { display: false } } + } +}; + +const channelsBarChartOptions = { + ...competitorsBarChartOptions, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { label: (ctx) => `${ctx.formattedValue}%` } + } + } +}; + const refreshV2 = async () => { if (!v2AnalysisId.value) return; v2Loading.value = true; @@ -1906,6 +1992,58 @@ const buildCompetitorsHeatmapData = (competitors) => { }; }; +// --- ВСТАВИТЬ СЮДА --- + +// Сборка данных для ДОХОДА (Фото 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 @@ -1973,6 +2111,124 @@ const mapChartsDataToSegments = (chartsData) => { const segments = []; + // ========================================== + // 1. НОВАЯ ЛОГИКА (V2 JSON - Исправления графиков) + // ========================================== + + // --- График ДОХОДА (Горизонтальный) и ВОЗРАСТА --- + if (chartsData.target_audience || chartsData.targetAudience) { + const source = chartsData.target_audience || chartsData.targetAudience; + + // 1.1 Доход (Горизонтальный) + const incomeChart = buildIncomeChartData(source); + if (incomeChart) { + segments.push({ + type: 'chart', + key: 'incomeByAge', + title: 'Средний доход по возрастным группам', + chartType: 'bar', + data: incomeChart, + options: horizontalBarChartOptions, // <-- Горизонтальный вид + chartHeight: 'height: 350px' + }); + } + + // 1.2 Возрастная структура (Цифры на барах) + const ageStruct = source.age_structure || source.ageStructure; + if (ageStruct) { + const labels = Object.keys(ageStruct); + const values = Object.values(ageStruct).map(v => parseFloat(String(v).replace('%',''))); + segments.push({ + type: 'chart', + key: 'ageStructure', + title: 'Возрастная структура', + chartType: 'bar', + data: { + labels, + datasets: [{ + data: values, + backgroundColor: getMarketingColors(values.length), + unit: '%' + }] + }, + options: competitorsBarChartOptions, // Цифры сверху + chartHeight: 'height: 300px' + }); + } + } + + // --- График КАНАЛОВ (Динамический) --- + if (chartsData.acquisition_channels || chartsData.acquisitionChannels) { + const source = chartsData.acquisition_channels || chartsData.acquisitionChannels; + const channelsChart = buildChannelsChartData(source); + if (channelsChart) { + segments.push({ + type: 'chart', + key: 'acquisitionChannels', + title: 'Каналы привлечения', + chartType: 'bar', + data: channelsChart, + options: channelsBarChartOptions, // Вертикальные с % + chartHeight: 'height: 350px' + }); + } + } + + // --- Факторы принятия решений и Причины отказов --- + if (chartsData.demand_and_behavior || chartsData.demandAndBehavior) { + const source = chartsData.demand_and_behavior || chartsData.demandAndBehavior; + + // Факторы + const factors = source.decision_factors || source.decisionFactors; + if (factors) { + const labels = Object.keys(factors).map(k => k.replace(/_/g, ' ')); + const values = Object.values(factors).map(v => parseFloat(String(v).replace('%',''))); + segments.push({ + type: 'chart', + key: 'decisionFactors', + title: 'Факторы принятия решения', + chartType: 'bar', + data: { + labels, + datasets: [{ + data: values, + backgroundColor: '#8B5CF6', + unit: '%' + }] + }, + options: competitorsBarChartOptions, // Цифры сверху + chartHeight: 'height: 300px' + }); + } + + // Причины отказов + const refusals = source.reasons_for_refusal || source.reasonsForRefusal; + if (refusals) { + const labels = Object.keys(refusals).map(k => k.replace(/_/g, ' ')); + const values = Object.values(refusals).map(v => parseFloat(String(v).replace('%',''))); + segments.push({ + type: 'chart', + key: 'refusalReasons', + title: 'Причины отказов', + chartType: 'bar', + data: { + labels, + datasets: [{ + data: values, + backgroundColor: '#EF4444', + unit: '%' + }] + }, + options: competitorsBarChartOptions, // Цифры сверху + chartHeight: 'height: 300px' + }); + } + } + + // ========================================== + // 2. СТАРАЯ ЛОГИКА (Legacy Support) + // ========================================== + // Handle case when chartsData is an array (shouldn't happen, but handle it) if (Array.isArray(chartsData)) { // If it's an array of competitors, process it @@ -2006,7 +2262,6 @@ const mapChartsDataToSegments = (chartsData) => { } // 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) { @@ -2017,8 +2272,6 @@ const mapChartsDataToSegments = (chartsData) => { segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix), keyTakeaways: chartsData.keyTakeaways || [] }); - // Return early to avoid processing it again as audienceSegmentation property - // But continue to check for other properties like competitors } } @@ -2054,20 +2307,16 @@ const mapChartsDataToSegments = (chartsData) => { } // 3. Competitors Analysis - // Check if competitors is an array (new format) or object with competitors array (old format) if (chartsData.competitors) { let competitorsArray = null; if (Array.isArray(chartsData.competitors)) { - // New format: competitors is directly an array competitorsArray = chartsData.competitors; } else if (chartsData.competitors.competitors && Array.isArray(chartsData.competitors.competitors)) { - // Old format: competitors is an object with competitors array inside competitorsArray = chartsData.competitors.competitors; } if (competitorsArray && competitorsArray.length > 0) { - // Build bar charts for reach and followers using flat structure const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach'); const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers'); @@ -2094,7 +2343,6 @@ const mapChartsDataToSegments = (chartsData) => { }); } - // SWOT Competitors (if exists in competitors object) if (chartsData.competitors.swotCompetitors && typeof chartsData.competitors.swotCompetitors === 'object') { segments.push({ type: 'swotCompetitors', @@ -2106,7 +2354,7 @@ const mapChartsDataToSegments = (chartsData) => { } } - // 4. Comparison Table (in root of chartsData) + // 4. Comparison Table if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) { segments.push({ type: 'comparisonTable', @@ -2114,7 +2362,7 @@ const mapChartsDataToSegments = (chartsData) => { }); } - // 5. SWOT Competitors (in root of chartsData, if exists) + // 5. SWOT Competitors if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') { segments.push({ type: 'swotCompetitors', @@ -2149,7 +2397,7 @@ const mapChartsDataToSegments = (chartsData) => { }); } - // 8. Conversion Funnel (supports Chart.js format too) + // 8. Conversion Funnel if (chartsData.conversionFunnel) { const chartData = isChartJsData(chartsData.conversionFunnel) ? normalizeChartJsData(chartsData.conversionFunnel, 'bar') : buildConversionFunnelChartData(chartsData.conversionFunnel); if (chartData) { @@ -2165,7 +2413,7 @@ const mapChartsDataToSegments = (chartsData) => { } } - // 9. Channels Potential (supports Chart.js format too) + // 9. Channels Potential if (chartsData.channelsPotential) { const chartData = isChartJsData(chartsData.channelsPotential) ? normalizeChartJsData(chartsData.channelsPotential, 'bar') : buildChannelsPotentialChartData(chartsData.channelsPotential); if (chartData) { @@ -2182,7 +2430,7 @@ const mapChartsDataToSegments = (chartsData) => { } } - // 11. Audience charts (Chart.js config) + // 11. Audience charts (Legacy) if (chartsData.audienceAge) { const chartData = normalizeChartJsData(chartsData.audienceAge, 'bar'); if (chartData) { @@ -2212,7 +2460,7 @@ const mapChartsDataToSegments = (chartsData) => { } } - // 10. SWOT (if exists for backward compatibility) + // 10. SWOT if (chartsData.swot && typeof chartsData.swot === 'object') { segments.push({ type: 'swot', @@ -2227,7 +2475,6 @@ const mapChartsDataToSegments = (chartsData) => { return segments; }; - const mapJsonBlockToSegment = (identifier, data, rawText) => { const key = identifier?.toLowerCase(); switch (key) {