From 45a957287f9e64c0ba826d9f6e414e8e94c6b645 Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 20 Jan 2026 18:28:52 +0000 Subject: [PATCH] Update MarketingAnalysis.vue --- .../pages/marketing/MarketingAnalysis.vue | 502 +++++++++++------- 1 file changed, 313 insertions(+), 189 deletions(-) diff --git a/src/views/pages/marketing/MarketingAnalysis.vue b/src/views/pages/marketing/MarketingAnalysis.vue index c99e2a9..4d4905c 100644 --- a/src/views/pages/marketing/MarketingAnalysis.vue +++ b/src/views/pages/marketing/MarketingAnalysis.vue @@ -934,10 +934,10 @@ 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.save(); // Сохраняем состояние контекста + ctx.font = 'bold 12px "Inter", sans-serif'; ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; + ctx.textBaseline = 'middle'; chart.data.datasets.forEach((dataset, i) => { const meta = chart.getDatasetMeta(i); @@ -946,30 +946,106 @@ const drawBarValues = (chart) => { const value = dataset.data[index]; if (value !== null && value !== undefined && value !== 0) { let label = value.toLocaleString(); - // Добавляем % если это проценты - if ((dataset.unit === '%') || (typeof value === 'string' && value.includes('%'))) { + // Добавляем %, если это указано в датасете или значение похоже на процент + if ((dataset.unit === '%') || (typeof dataset.data[index] === '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.textBaseline = 'middle'; - ctx.fillText(label, bar.x + 5, bar.y); + ctx.fillText(label, bar.x + 8, bar.y); } else { - // Для вертикальных: рисуем сверху - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(label, bar.x, bar.y - 5); + // Вертикальный график (Требование 1, 2, 3): Цифра ВНУТРИ бара + // Используем белый цвет для контраста на цветном фоне + ctx.fillStyle = '#ffffff'; + + // Если бар слишком маленький, рисуем сверху, иначе внутри + const barHeight = Math.abs(bar.base - bar.y); + if (barHeight > 20) { + ctx.fillText(label, bar.x, bar.y + (barHeight / 2)); // По центру бара + } else { + // Если бар слишком мелкий, рисуем сверху темным цветом + ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937'; + ctx.fillText(label, bar.x, bar.y - 10); + } } } }); } }); + ctx.restore(); }; +// --- 2. НОВЫЕ ОПЦИИ ДЛЯ ГРАФИКОВ (Требование: убрать нижние цифры и оси) --- +const verticalBarChartInsideOptions = { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 500, + onComplete: (animation) => drawBarValues(animation.chart) + }, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + callbacks: { + label: (context) => `${context.formattedValue}%` + } + } + }, + scales: { + y: { + display: false, // Скрываем ось Y полностью + beginAtZero: true, + grid: { display: false } + }, + x: { + grid: { display: false }, + ticks: { + font: { size: 11, weight: '600' }, + color: '#64748b' // Цвет подписей категорий (Высокая цена, Нет отзывов и т.д.) + } + } + }, + layout: { padding: { top: 20, bottom: 0 } } +}; + +// Опции для горизонтальных баров (как на фото 4/5) +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 }, // Скрываем цифры снизу + y: { + grid: { display: false }, + ticks: { + font: { size: 12, weight: '500' }, + crossAlign: 'far' + } + } + } +}; + +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 competitorsBarChartOptions = { responsive: true, maintainAspectRatio: false, @@ -2089,151 +2165,207 @@ const mapChartsDataToSegments = (chartsData) => { const segments = []; + // Нормализация: работаем либо с полем sections (новый бэк), либо с корнем (если пришло без обертки) + const sections = chartsData.sections || chartsData; + // ========================================== - // 1. НОВАЯ ЛОГИКА (V2 JSON - Исправления графиков) + // 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; + // --- Блок: Спрос и поведение (Фото 1: Причины отказов, Фото 6: Факторы) --- + const demandSection = sections.demand_and_behavior || sections.demandAndBehavior; + if (demandSection) { + // 1. Причины отказов (Фото 1: Вертикальные, цифры внутри, оранжевый) + const refusals = demandSection.reasons_for_refusal || demandSection.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({ + const { labels, values } = parsePercentageData(refusals); + segments.push({ type: 'chart', key: 'refusalReasons', title: 'Причины отказов', chartType: 'bar', data: { labels, - datasets: [{ - data: values, - backgroundColor: '#EF4444', - unit: '%' + datasets: [{ + data: values, + backgroundColor: '#fb923c', // Оранжевый + borderRadius: 6, + barPercentage: 0.7, + unit: '%' }] }, - options: competitorsBarChartOptions, // Цифры сверху + options: verticalBarChartInsideOptions, // Спец. опции: без осей, цифры внутри chartHeight: 'height: 300px' - }); + }); + } + + // 2. Факторы принятия решения (Фото 6 -> дизайн как 1,2,3) + 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: 6, + barPercentage: 0.7, + unit: '%' + }] + }, + options: verticalBarChartInsideOptions, + chartHeight: 'height: 300px' + }); + } + } + + // --- Блок: Целевая аудитория (Фото 3: Возраст, Фото 5: Доход) --- + const audienceSection = sections.target_audience || sections.targetAudience; + if (audienceSection) { + // 3. Возрастная структура (Фото 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: 6, + barPercentage: 0.7, + unit: '%' + }] + }, + options: verticalBarChartInsideOptions, + chartHeight: 'height: 300px' + }); + } + + // 4. Доход (Фото 5 -> Дизайн как на фото 4: Вертикальные, компактные) + // Если данных в income_by_age нет, проверяем metrics или используем заглушку, чтобы блок не пропал + 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 { + // Если с бэка пришел null, но нужно показать структуру (фоллбэк) + labels = ['Низкий', 'Средний', 'Высокий']; + values = [20, 50, 30]; + } + + segments.push({ + type: 'chart', + key: 'incomeLevel', + title: 'Уровень дохода аудитории', + chartType: 'bar', + data: { + labels, + datasets: [{ + data: values, + backgroundColor: '#34d399', // Мятный/Зеленый + borderRadius: 6, + barPercentage: 0.7, + unit: '%' + }] + }, + options: verticalBarChartInsideOptions, // Дизайн с цифрами внутри + chartHeight: 'height: 300px' + }); + } + } + + // --- Блок: Каналы привлечения (Фото 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: 6, + barPercentage: 0.6, // Чуть тоньше, как на фото 4 + unit: '%' + }] + }, + options: verticalBarChartInsideOptions, + chartHeight: 'height: 300px' + }); + } + } + } + + // --- Блок: Сезонность (Line Chart) --- + const marketSection = sections.market_overview || sections.marketOverview; + if (marketSection) { + const seasonalData = marketSection.monthly_online_index || marketSection.monthlyOnlineIndex; + if (seasonalData) { + const labels = Object.keys(seasonalData); + const values = Object.values(seasonalData); + segments.push({ + type: 'chart', + key: 'seasonality', + title: 'Динамика спроса (Сезонность)', + chartType: 'line', + data: { + labels, + datasets: [{ + label: 'Индекс спроса', + data: values, + borderColor: '#6366f1', + backgroundColor: 'rgba(99, 102, 241, 0.2)', + fill: true, + tension: 0.4, + pointRadius: 4 + }] + }, + options: seasonalityChartOptions, + chartHeight: 'height: 350px' + }); } } // ========================================== - // 2. СТАРАЯ ЛОГИКА (Legacy Support) + // 2. СТАРАЯ ЛОГИКА (LEGACY SUPPORT) - БЕЗ ИЗМЕНЕНИЙ // ========================================== - // Handle case when chartsData is an array (shouldn't happen, but handle it) + // Handle case when chartsData is an array if (Array.isArray(chartsData)) { - // If it's an array of competitors, process it if (chartsData.length > 0 && chartsData[0]?.name && (chartsData[0]?.channelsHeatmap || chartsData[0]?.reach || chartsData[0]?.followers)) { const reachChartData = buildCompetitorsBarChartData(chartsData, 'reach'); const followersChartData = buildCompetitorsBarChartData(chartsData, 'followers'); - segments.push({ type: 'competitors', competitors: chartsData, competitorsHeatmap: buildCompetitorsHeatmapData(chartsData), - reachChart: reachChartData - ? { - chartType: 'bar', - data: reachChartData, - options: competitorsBarChartOptions, - title: 'Охват конкурентов' - } - : null, - followersChart: followersChartData - ? { - chartType: 'bar', - data: followersChartData, - options: competitorsBarChartOptions, - title: 'Подписчики конкурентов' - } - : null + reachChart: reachChartData ? { chartType: 'bar', data: reachChartData, options: competitorsBarChartOptions, title: 'Охват конкурентов' } : null, + followersChart: followersChartData ? { chartType: 'bar', data: followersChartData, options: competitorsBarChartOptions, title: 'Подписчики конкурентов' } : null }); } return segments; @@ -2256,7 +2388,6 @@ const mapChartsDataToSegments = (chartsData) => { // 1. Audience Segmentation (nested property) if (chartsData.audienceSegmentation) { const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation); - if (ageGroupsChartData || gendersChartData) { segments.push({ type: 'audienceSegmentation', @@ -2271,7 +2402,6 @@ const mapChartsDataToSegments = (chartsData) => { // 2. Market Share Chart (in root of chartsData) if (chartsData.marketShareChart) { const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart, 'pie'); - if (marketShareChartData) { segments.push({ type: 'marketShareChart', @@ -2287,7 +2417,6 @@ const mapChartsDataToSegments = (chartsData) => { // 3. Competitors Analysis if (chartsData.competitors) { let competitorsArray = null; - if (Array.isArray(chartsData.competitors)) { competitorsArray = chartsData.competitors; } else if (chartsData.competitors.competitors && Array.isArray(chartsData.competitors.competitors)) { @@ -2297,30 +2426,14 @@ const mapChartsDataToSegments = (chartsData) => { if (competitorsArray && competitorsArray.length > 0) { const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach'); const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers'); - segments.push({ type: 'competitors', competitors: competitorsArray, competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray), - reachChart: reachChartData - ? { - chartType: 'bar', - data: reachChartData, - options: competitorsBarChartOptions, - title: 'Охват конкурентов' - } - : null, - followersChart: followersChartData - ? { - chartType: 'bar', - data: followersChartData, - options: competitorsBarChartOptions, - title: 'Подписчики конкурентов' - } - : null + reachChart: reachChartData ? { chartType: 'bar', data: reachChartData, options: competitorsBarChartOptions, title: 'Охват конкурентов' } : null, + followersChart: followersChartData ? { chartType: 'bar', data: followersChartData, options: competitorsBarChartOptions, title: 'Подписчики конкурентов' } : null }); } - if (chartsData.competitors.swotCompetitors && typeof chartsData.competitors.swotCompetitors === 'object') { segments.push({ type: 'swotCompetitors', @@ -2351,19 +2464,23 @@ const mapChartsDataToSegments = (chartsData) => { }); } - // 6. Seasonality + // 6. Seasonality (Old structure) if (chartsData.seasonality && typeof chartsData.seasonality === 'object') { - const chartData = buildSeasonalityChartData(chartsData.seasonality); - if (chartData) { - segments.push({ - type: 'chart', - key: 'seasonality', - title: 'Сезонность спроса', - chartType: 'line', - data: chartData, - options: seasonalityChartOptions, - chartHeight: 'height: 300px' - }); + // Если мы уже добавили сезонность в V2 логике, не дублируем + const alreadyAdded = segments.some(s => s.key === 'seasonality'); + if (!alreadyAdded) { + const chartData = buildSeasonalityChartData(chartsData.seasonality); + if (chartData) { + segments.push({ + type: 'chart', + key: 'seasonality', + title: 'Сезонность спроса', + chartType: 'line', + data: chartData, + options: seasonalityChartOptions, + chartHeight: 'height: 300px' + }); + } } } @@ -2391,36 +2508,43 @@ const mapChartsDataToSegments = (chartsData) => { } } - // 9. Channels Potential + // 9. Channels Potential (Legacy) if (chartsData.channelsPotential) { - const chartData = isChartJsData(chartsData.channelsPotential) ? normalizeChartJsData(chartsData.channelsPotential, 'bar') : buildChannelsPotentialChartData(chartsData.channelsPotential); - if (chartData) { - segments.push({ - type: 'chart', - key: 'channelsPotential', - title: 'Потенциал каналов продвижения', - chartType: 'bar', - data: chartData, - options: channelsPotentialChartOptions, - chartHeight: 'height: 300px', - channels: Array.isArray(chartsData.channelsPotential) ? chartsData.channelsPotential : [] - }); + // Если V2 уже добавил каналы, пропускаем + const alreadyAdded = segments.some(s => s.key === 'acquisitionChannels'); + if (!alreadyAdded) { + const chartData = isChartJsData(chartsData.channelsPotential) ? normalizeChartJsData(chartsData.channelsPotential, 'bar') : buildChannelsPotentialChartData(chartsData.channelsPotential); + if (chartData) { + segments.push({ + type: 'chart', + key: 'channelsPotential', + title: 'Потенциал каналов продвижения', + chartType: 'bar', + data: chartData, + options: channelsPotentialChartOptions, + chartHeight: 'height: 300px', + channels: Array.isArray(chartsData.channelsPotential) ? chartsData.channelsPotential : [] + }); + } } } // 11. Audience charts (Legacy) if (chartsData.audienceAge) { - const chartData = normalizeChartJsData(chartsData.audienceAge, 'bar'); - if (chartData) { - segments.push({ - type: 'chart', - key: 'audienceAge', - title: 'Распределение по возрасту', - chartType: 'bar', - data: chartData, - options: competitorsBarChartOptions, - chartHeight: 'height: 350px' - }); + const alreadyAdded = segments.some(s => s.key === 'ageStructure'); + if (!alreadyAdded) { + const chartData = normalizeChartJsData(chartsData.audienceAge, 'bar'); + if (chartData) { + segments.push({ + type: 'chart', + key: 'audienceAge', + title: 'Распределение по возрасту', + chartType: 'bar', + data: chartData, + options: competitorsBarChartOptions, + chartHeight: 'height: 350px' + }); + } } } if (chartsData.audienceGender) {