This commit is contained in:
root
2026-01-25 21:43:25 +05:00
parent 2906dace69
commit d5a7748a28
+538 -15
View File
@@ -448,6 +448,171 @@
<HeatmapTable :matrix="segment.matrix" :title="segment.title || 'Матрица каналов по сегментам'" />
</div>
<!-- New Competitive Environment Component -->
<div v-else-if="segment.type === 'competitiveEnvironment'">
<div class="mb-6">
<h3 class="text-2xl font-semibold text-surface-900 dark:text-surface-0 mb-4">
<i class="pi pi-chart-line mr-2 text-primary-500"></i>{{ segment.title || 'Конкурентная среда' }}
</h3>
<!-- Metrics KPI Cards -->
<div v-if="segment.metrics" class="grid mb-6">
<div class="col-12 md:col-3 mb-3">
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full">
<div class="flex align-items-center mb-2">
<i class="pi pi-sitemap text-primary-500 mr-2 text-xl"></i>
<span class="text-sm font-medium text-surface-600 dark:text-surface-400">Доля лидера</span>
</div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-0">
{{ segment.metrics.leader_market_share || '—' }}
</div>
<div v-if="segment.metrics.market_leader_name" class="text-xs text-surface-500 dark:text-surface-400 mt-1">
{{ segment.metrics.market_leader_name }}
</div>
</div>
</div>
<div class="col-12 md:col-3 mb-3">
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full">
<div class="flex align-items-center mb-2">
<i class="pi pi-chart-bar text-green-500 mr-2 text-xl"></i>
<span class="text-sm font-medium text-surface-600 dark:text-surface-400">Уровень конкуренции</span>
</div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-0">
{{ segment.metrics.competition_level || '—' }}
</div>
</div>
</div>
<div class="col-12 md:col-3 mb-3">
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full">
<div class="flex align-items-center mb-2">
<i class="pi pi-wallet text-orange-500 mr-2 text-xl"></i>
<span class="text-sm font-medium text-surface-600 dark:text-surface-400">Давление рекламы</span>
</div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-0">
{{ segment.metrics.ad_pressure || '—' }}
</div>
</div>
</div>
</div>
<!-- Visibility Chart -->
<div v-if="segment.visibilityChart" class="mb-6">
<div class="flex align-items-center mb-3">
<i class="pi pi-chart-bar text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ segment.visibilityChart.title }}</h4>
</div>
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
<div class="chart-container" :style="segment.visibilityChart.chartHeight">
<Chart
v-if="hasRenderableChartData(segment.visibilityChart.data)"
:type="segment.visibilityChart.chartType"
:data="segment.visibilityChart.data"
:options="segment.visibilityChart.options"
@chart:click="onChartClick"
@chart:hover="onChartHover"
ref="visibilityChartRef"
/>
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">
Нет данных для отображения
</div>
</div>
</div>
</div>
<!-- Competitors Table -->
<div v-if="segment.competitors && segment.competitors.length > 0" 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="competitive-environment-table w-full border-collapse">
<thead>
<tr>
<th class="competitive-env-th">Название</th>
<th class="competitive-env-th">Сайт</th>
<th class="competitive-env-th text-center">Видимость</th>
<th class="competitive-env-th">Ключевые преимущества</th>
<th class="competitive-env-th text-center">Ценовой сегмент</th>
</tr>
</thead>
<tbody>
<tr
v-for="(competitor, idx) in segment.competitors"
:key="competitor.name || idx"
:class="['competitive-env-tr', { 'competitive-env-tr-highlighted': highlightedRowIndex === idx }]"
@mouseenter="highlightRow(idx)"
@mouseleave="highlightRow(null)"
@click="handleRowClick(competitor)"
>
<td class="competitive-env-td font-semibold">
<i class="pi pi-building text-primary-500 mr-2"></i>{{ competitor.name }}
</td>
<td class="competitive-env-td">
<a
v-if="competitor.website"
:href="competitor.website"
target="_blank"
rel="noopener noreferrer"
class="text-primary-500 hover:text-primary-700 dark:hover:text-primary-400 flex align-items-center"
@click.stop
>
<i class="pi pi-external-link mr-1"></i>
<span class="text-sm">{{ formatWebsite(competitor.website) }}</span>
</a>
<span v-else class="text-surface-400"></span>
</td>
<td class="competitive-env-td text-center">
<div class="flex align-items-center justify-content-center">
<span
class="competitive-env-score-badge"
:class="getScoreClass(competitor.visibility_score)"
>
{{ competitor.visibility_score || 0 }}%
</span>
</div>
</td>
<td class="competitive-env-td">
<div v-if="competitor.key_strengths && competitor.key_strengths.length > 0" class="flex flex-wrap gap-1">
<Tag
v-for="(strength, sIdx) in competitor.key_strengths"
:key="sIdx"
:value="strength"
severity="success"
class="text-xs"
/>
</div>
<span v-else class="text-surface-400"></span>
</td>
<td class="competitive-env-td text-center">
<Tag
v-if="competitor.price_segment"
:value="competitor.price_segment"
:severity="getPriceSegmentSeverity(competitor.price_segment)"
/>
<span v-else class="text-surface-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Conclusion -->
<div v-if="segment.conclusion" class="mt-6">
<div class="p-4 border-round bg-primary-50 dark:bg-primary-900 border-1 border-primary-200 dark:border-primary-700">
<div class="flex align-items-start">
<i class="pi pi-info-circle text-primary-500 mr-2 mt-1 flex-shrink-0"></i>
<div>
<h4 class="text-lg font-semibold text-primary-700 dark:text-primary-300 mb-2">Заключение</h4>
<p class="text-surface-700 dark:text-surface-300 m-0">{{ segment.conclusion }}</p>
</div>
</div>
</div>
</div>
</div>
</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>
@@ -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;
}
</style>