diff --git a/src/views/pages/marketing/MarketingAnalysis.vue b/src/views/pages/marketing/MarketingAnalysis.vue index ab54262..30df7d4 100644 --- a/src/views/pages/marketing/MarketingAnalysis.vue +++ b/src/views/pages/marketing/MarketingAnalysis.vue @@ -448,6 +448,171 @@ + +
+
+

+ {{ segment.title || 'Конкурентная среда' }} +

+ + +
+
+
+
+ + Доля лидера +
+
+ {{ segment.metrics.leader_market_share || '—' }} +
+
+ {{ segment.metrics.market_leader_name }} +
+
+
+
+
+
+ + Уровень конкуренции +
+
+ {{ segment.metrics.competition_level || '—' }} +
+
+
+
+
+
+ + Давление рекламы +
+
+ {{ segment.metrics.ad_pressure || '—' }} +
+
+
+
+ + +
+
+ +

{{ segment.visibilityChart.title }}

+
+
+
+ +
+ Нет данных для отображения +
+
+
+
+ + +
+
+ +

Таблица конкурентов

+
+
+ + + + + + + + + + + + + + + + + + + +
НазваниеСайтВидимостьКлючевые преимуществаЦеновой сегмент
+ {{ competitor.name }} + + + + {{ formatWebsite(competitor.website) }} + + + +
+ + {{ competitor.visibility_score || 0 }}% + +
+
+
+ +
+ +
+ + +
+
+
+ + +
+
+
+ +
+

Заключение

+

{{ segment.conclusion }}

+
+
+
+
+
+
+

Анализ конкурентов

@@ -1958,6 +2123,104 @@ const buildCompetitorsBarChartData = (competitors, metricType = 'reach') => { }; }; +// Build competitors visibility score chart data (horizontal bar chart) +const buildCompetitorsVisibilityChartData = (competitors) => { + if (!Array.isArray(competitors) || competitors.length === 0) return null; + + // Sort by visibility_score descending for better visualization + const sortedCompetitors = [...competitors].sort((a, b) => (b.visibility_score || 0) - (a.visibility_score || 0)); + + return { + labels: sortedCompetitors.map((comp) => comp.name), + datasets: [ + { + label: 'Видимость (score)', + data: sortedCompetitors.map((comp) => comp.visibility_score || 0), + backgroundColor: sortedCompetitors.map((comp) => { + const score = comp.visibility_score || 0; + // Color coding: high (green), medium (yellow), low (red) + if (score >= 70) return '#10B981'; // green-500 + if (score >= 40) return '#F59E0B'; // amber-500 + return '#EF4444'; // red-500 + }), + borderColor: '#ffffff', + borderWidth: 2, + borderRadius: 4 + } + ] + }; +}; + +// Process competitive environment data into segment format +const processCompetitiveEnvironmentData = (competitiveEnv) => { + if (!competitiveEnv || typeof competitiveEnv !== 'object') return null; + + const competitors = competitiveEnv.competitors || []; + if (!Array.isArray(competitors) || competitors.length === 0) return null; + + // Build visibility chart data + const visibilityChartData = buildCompetitorsVisibilityChartData(competitors); + + return { + type: 'competitiveEnvironment', + title: competitiveEnv.title || 'Конкурентная среда', + metrics: competitiveEnv.metrics || {}, + competitors: competitors, + visibilityChart: visibilityChartData + ? { + chartType: 'bar', + data: visibilityChartData, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + layout: { padding: { right: 40 } }, + animation: { + duration: 500, + onComplete: (animation) => drawBarValues(animation.chart) + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (context) => `Видимость: ${context.parsed.x}%` + } + } + }, + scales: { + x: { + display: true, + beginAtZero: true, + max: 100, + ticks: { + callback: function(value) { + return value + '%'; + }, + font: { size: 12 } + }, + grid: { + display: true, + drawBorder: false, + color: 'rgba(0,0,0,0.05)' + } + }, + y: { + grid: { display: false }, + ticks: { + font: { size: 12, weight: '500' }, + crossAlign: 'far' + } + } + } + }, + title: 'Видимость конкурентов', + chartHeight: 'height: 400px' + } + : null, + conclusion: competitiveEnv.conclusion || '' + }; +}; + // Build competitors heatmap data const buildCompetitorsHeatmapData = (competitors) => { if (!Array.isArray(competitors) || competitors.length === 0) return null; @@ -2303,7 +2566,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => { title: 'Распределение по возрасту', chartType: 'bar', data: chartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, chartHeight: 'height: 350px' }; } @@ -2389,7 +2652,23 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => { gendersChartData }; } + case 'competitiveenvironment': + case 'competitive_environment': { + // New structure: { title, metrics, competitors, conclusion } + const processed = processCompetitiveEnvironmentData(data); + if (processed) return processed; + // Fallback to old structure if processing fails + return null; + } case 'competitors': { + // Check if it's new structure (has metrics, competitors array with visibility_score) + if (data && typeof data === 'object' && !Array.isArray(data)) { + if (data.metrics && Array.isArray(data.competitors)) { + const processed = processCompetitiveEnvironmentData(data); + if (processed) return processed; + } + } + // Old structure: array of competitors if (!Array.isArray(data) || data.length === 0) return null; const reachChartData = buildCompetitorsBarChartData(data, 'reach'); const followersChartData = buildCompetitorsBarChartData(data, 'followers'); @@ -2402,7 +2681,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => { ? { chartType: 'bar', data: reachChartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, title: 'Охват конкурентов' } : null, @@ -2410,7 +2689,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => { ? { chartType: 'bar', data: followersChartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, title: 'Подписчики конкурентов' } : null @@ -2467,9 +2746,28 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => { return null; case 'CHART_COMPETITORS': + case 'CHART_COMPETITIVE_ENVIRONMENT': { + // Check for new competitive environment structure first + if (chartsData.competitiveEnvironment || chartsData.competitive_environment) { + const competitiveEnv = chartsData.competitiveEnvironment || chartsData.competitive_environment; + const processed = processCompetitiveEnvironmentData(competitiveEnv); + if (processed) return processed; + } + // Fallback to old competitors structure const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || []; if (competitorsArray.length > 0) { + // Check if it's new structure (has visibility_score) + if (competitorsArray[0]?.visibility_score !== undefined) { + const processed = processCompetitiveEnvironmentData({ + competitors: competitorsArray, + metrics: chartsData.metrics || {}, + title: chartsData.title || 'Конкурентная среда', + conclusion: chartsData.conclusion || '' + }); + if (processed) return processed; + } + // Old structure const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach'); const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers'); @@ -2481,7 +2779,7 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => { ? { chartType: 'bar', data: reachChartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, title: 'Охват конкурентов' } : null, @@ -2489,7 +2787,7 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => { ? { chartType: 'bar', data: followersChartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, title: 'Подписчики конкурентов' } : null @@ -2521,6 +2819,7 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => { return segment; } return null; + } case 'CHART_SWOT': // Check for swotCompetitors first @@ -2602,7 +2901,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => { title: 'Распределение по возрасту', chartType: 'bar', data: chartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, chartHeight: 'height: 350px' }; } @@ -2634,9 +2933,29 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => { } return null; - case 'competitors': + case 'competitiveenvironment': + case 'competitive_environment': { + const competitiveEnv = chartsData.competitiveEnvironment || chartsData.competitive_environment; + if (competitiveEnv) { + const processed = processCompetitiveEnvironmentData(competitiveEnv); + if (processed) return processed; + } + return null; + } + case 'competitors': { const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || []; if (competitorsArray.length > 0) { + // Check if it's new structure (has visibility_score) + if (competitorsArray[0]?.visibility_score !== undefined) { + const processed = processCompetitiveEnvironmentData({ + competitors: competitorsArray, + metrics: chartsData.metrics || {}, + title: chartsData.title || 'Конкурентная среда', + conclusion: chartsData.conclusion || '' + }); + if (processed) return processed; + } + // Old structure const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach'); const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers'); @@ -2648,7 +2967,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => { ? { chartType: 'bar', data: reachChartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, title: 'Охват конкурентов' } : null, @@ -2656,7 +2975,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => { ? { chartType: 'bar', data: followersChartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, title: 'Подписчики конкурентов' } : null @@ -2688,6 +3007,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => { return segment; } return null; + } case 'swot': // Check for swotCompetitors first @@ -2836,9 +3156,27 @@ const tryParseJsonSegment = (text) => { try { const parsedJson = JSON.parse(trimmed); + // Check if it's new competitive environment structure + if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) { + if (parsedJson.competitors && Array.isArray(parsedJson.competitors) && parsedJson.metrics) { + const processed = processCompetitiveEnvironmentData(parsedJson); + if (processed) return processed; + } + } + // Check if it's an array of competitors if (Array.isArray(parsedJson) && parsedJson.length > 0) { - // Check if it looks like competitors data + // Check if it's new structure (has visibility_score) + if (parsedJson[0]?.visibility_score !== undefined) { + const processed = processCompetitiveEnvironmentData({ + competitors: parsedJson, + metrics: {}, + title: 'Конкурентная среда', + conclusion: '' + }); + if (processed) return processed; + } + // Check if it looks like old competitors data if (parsedJson[0]?.name && (parsedJson[0]?.channelsHeatmap || parsedJson[0]?.reach || parsedJson[0]?.followers)) { return { type: 'competitors', @@ -2855,7 +3193,7 @@ const tryParseJsonSegment = (text) => { const chartType = parsedJson.chartType || 'bar'; // Use appropriate options based on chart type - const options = chartType === 'pie' || chartType === 'doughnut' ? pieChartOptions : chartType === 'line' ? seasonalityChartOptions : competitorsBarChartOptions; + const options = chartType === 'pie' || chartType === 'doughnut' ? pieChartOptions : chartType === 'line' ? seasonalityChartOptions : horizontalBarChartOptions; // Normalize chart data (colors/borders, safety) const chartData = normalizeChartJsData(parsedJson, chartType); @@ -3001,7 +3339,7 @@ const chartsDataSegments = computed(() => { ? { chartType: 'bar', data: reachChartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, title: 'Охват конкурентов' } : null, @@ -3009,7 +3347,7 @@ const chartsDataSegments = computed(() => { ? { chartType: 'bar', data: followersChartData, - options: competitorsBarChartOptions, + options: horizontalBarChartOptions, title: 'Подписчики конкурентов' } : null @@ -3058,13 +3396,24 @@ const chartsDataSegments = computed(() => { const parsedContent = computed(() => { let segments = []; + // Check for v2ReportNormalized competitive_environment first + if (v2ReportNormalized.value?.sections?.competitive_environment) { + const competitiveEnv = v2ReportNormalized.value.sections.competitive_environment; + const processed = processCompetitiveEnvironmentData(competitiveEnv); + if (processed) { + segments.push(processed); + } + } + // 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); + const parsedSegments = parseFullAnalysisWithCharts(report.value.fullAnalysis, report.value.chartsData); + segments = [...segments, ...parsedSegments]; } // If only chartsData exists, use it else if (report.value?.chartsData) { - segments = chartsDataSegments.value; + const chartSegments = chartsDataSegments.value; + segments = [...segments, ...chartSegments]; } // Otherwise, fall back to parsing fullAnalysis with JSON blocks (for old data) else if (report.value?.fullAnalysis) { @@ -3128,8 +3477,32 @@ const parsedContent = computed(() => { return segments.filter((segment) => { // If segment doesn't have a type, try to process it if (!segment || !segment.type) { + // Check if it's new competitive environment structure + if (segment && typeof segment === 'object' && !Array.isArray(segment)) { + if (segment.competitors && Array.isArray(segment.competitors) && segment.metrics) { + const processed = processCompetitiveEnvironmentData(segment); + if (processed) { + segments[segments.indexOf(segment)] = processed; + return true; + } + } + } // If it's an array (like competitors), process it if (Array.isArray(segment) && segment.length > 0 && segment[0]?.name) { + // Check if it's new structure (has visibility_score) + if (segment[0]?.visibility_score !== undefined) { + const processed = processCompetitiveEnvironmentData({ + competitors: segment, + metrics: {}, + title: 'Конкурентная среда', + conclusion: '' + }); + if (processed) { + segments[segments.indexOf(segment)] = processed; + return true; + } + } + // Old structure const processed = { type: 'competitors', competitors: segment, @@ -3341,6 +3714,79 @@ const goToStrategy = () => { } }; +// Competitive Environment: Chart and Table Synchronization +const highlightedRowIndex = ref(null); +const visibilityChartRef = ref(null); + +const highlightRow = (index) => { + highlightedRowIndex.value = index; + // Highlight corresponding bar in chart if chart is available + if (visibilityChartRef.value && visibilityChartRef.value.chart && index !== null) { + const chart = visibilityChartRef.value.chart; + const meta = chart.getDatasetMeta(0); + if (meta && meta.data[index]) { + // Reset all bars + meta.data.forEach((bar) => { + bar._model.backgroundColor = bar._view.backgroundColor; + }); + // Highlight the hovered bar + const hoveredBar = meta.data[index]; + if (hoveredBar) { + const originalColor = hoveredBar._view.backgroundColor; + hoveredBar._model.backgroundColor = originalColor + 'CC'; // Add transparency + chart.update('none'); + } + } + } +}; + +const onChartHover = (event, elements) => { + if (elements && elements.length > 0) { + const elementIndex = elements[0].index; + highlightedRowIndex.value = elementIndex; + } +}; + +const onChartClick = (event, elements) => { + if (elements && elements.length > 0) { + const elementIndex = elements[0].index; + // Could trigger some action on click + } +}; + +const handleRowClick = (competitor) => { + if (competitor.website) { + window.open(competitor.website, '_blank', 'noopener,noreferrer'); + } +}; + +const formatWebsite = (url) => { + if (!url) return ''; + try { + const urlObj = new URL(url); + return urlObj.hostname.replace('www.', ''); + } catch { + return url; + } +}; + +const getScoreClass = (score) => { + if (score >= 70) return 'competitive-env-score-high'; + if (score >= 40) return 'competitive-env-score-medium'; + return 'competitive-env-score-low'; +}; + +const getPriceSegmentSeverity = (segment) => { + const segmentLower = (segment || '').toLowerCase(); + if (segmentLower.includes('premium') || segmentLower.includes('высок') || segmentLower.includes('премиум')) { + return 'danger'; + } + if (segmentLower.includes('low') || segmentLower.includes('низк')) { + return 'success'; + } + return 'warning'; +}; + // Load analysis from query params onMounted(async () => { const queryAnalysisId = route.query.analysisId; @@ -3741,4 +4187,81 @@ onBeforeUnmount(() => { .comparison-table-tr:hover { background-color: var(--surface-50); } + +/* Competitive Environment Table Styles */ +.competitive-environment-table { + font-size: 0.9rem; +} + +.competitive-env-th { + padding: 1rem; + text-align: left; + font-weight: 600; + color: var(--text-color); + background-color: var(--surface-50); + border-bottom: 2px solid var(--surface-border); + white-space: nowrap; +} + +.competitive-env-td { + padding: 1rem; + border-bottom: 1px solid var(--surface-border); + color: var(--text-color); + vertical-align: middle; +} + +.competitive-env-tr { + transition: all 0.2s ease; + cursor: pointer; +} + +.competitive-env-tr:hover { + background-color: var(--surface-hover); + transform: translateX(2px); +} + +.competitive-env-tr-highlighted { + background-color: var(--primary-50); + border-left: 3px solid var(--primary-500); +} + +.competitive-env-score-badge { + display: inline-block; + padding: 0.375rem 0.75rem; + border-radius: 0.5rem; + font-weight: 600; + font-size: 0.875rem; + min-width: 3rem; + text-align: center; +} + +.competitive-env-score-high { + background-color: rgba(16, 185, 129, 0.1); + color: #10b981; +} + +.competitive-env-score-medium { + background-color: rgba(245, 158, 11, 0.1); + color: #f59e0b; +} + +.competitive-env-score-low { + background-color: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.dark .competitive-env-score-high { + background-color: rgba(16, 185, 129, 0.2); + color: #34d399; +} + +.dark .competitive-env-score-medium { + background-color: rgba(245, 158, 11, 0.2); + color: #fbbf24; +} + +.dark .competitive-env-score-low { + background-color: rgba(239, 68, 68, 0.2); + color: #f87171; +}