.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div v-if="normalized && normalized.data.length" class="heatmap-table mt-6">
|
||||
<div v-if="showHeader" class="flex align-items-center mb-3">
|
||||
<i v-if="icon" :class="icon" class="text-primary-500 mr-2"></i>
|
||||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ title }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
|
||||
<table class="heatmap-table__table w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="heatmap-table__th">Сегмент</th>
|
||||
<th v-for="col in normalized.columns" :key="col" class="heatmap-table__th text-center">
|
||||
{{ col }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in normalized.data" :key="idx" class="heatmap-table__tr">
|
||||
<td class="heatmap-table__td font-semibold">{{ getRowName(row) }}</td>
|
||||
<td v-for="col in normalized.columns" :key="col" class="heatmap-table__td text-center">
|
||||
<span class="heatmap-table__badge" :style="getCellStyle(row?.[col])">
|
||||
{{ formatCell(row?.[col]) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Supported shapes:
|
||||
* - Array of rows: [{ segmentName: '...', ChannelA: 'High', ChannelB: 'Low', ... }, ...]
|
||||
* - Object: { channels/columns: string[], data: Array<row> }
|
||||
*/
|
||||
matrix: { type: [Array, Object], default: null },
|
||||
title: { type: String, default: 'Матрица каналов по сегментам' },
|
||||
icon: { type: String, default: 'pi pi-th-large' },
|
||||
showHeader: { type: Boolean, default: true }
|
||||
});
|
||||
|
||||
const normalizeMatrix = (input) => {
|
||||
if (!input) return null;
|
||||
|
||||
const data = Array.isArray(input) ? input : Array.isArray(input.data) ? input.data : Array.isArray(input.matrix) ? input.matrix : [];
|
||||
if (!Array.isArray(data) || data.length === 0) return { columns: [], data: [] };
|
||||
|
||||
const cols = new Set(Array.isArray(input.columns) ? input.columns : Array.isArray(input.channels) ? input.channels : []);
|
||||
if (cols.size === 0) {
|
||||
data.forEach((row) => {
|
||||
if (!row || typeof row !== 'object') return;
|
||||
Object.keys(row).forEach((key) => {
|
||||
if (key !== 'segmentName' && key !== 'segment' && key !== 'name') cols.add(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { columns: Array.from(cols), data };
|
||||
};
|
||||
|
||||
const normalized = computed(() => normalizeMatrix(props.matrix));
|
||||
|
||||
const getRowName = (row) => {
|
||||
return row?.segmentName || row?.segment || row?.name || '-';
|
||||
};
|
||||
|
||||
const normalizeLevel = (value) => {
|
||||
if (value == null || value === '') return null;
|
||||
const s = String(value).trim().toLowerCase();
|
||||
|
||||
if (s === 'high' || s.includes('высок')) return 'high';
|
||||
if (s === 'medium' || s.includes('средн')) return 'medium';
|
||||
if (s === 'low' || s.includes('низк')) return 'low';
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCellStyle = (value) => {
|
||||
const level = normalizeLevel(value);
|
||||
if (level === 'high') return { backgroundColor: '#22c55e', color: '#ffffff' };
|
||||
if (level === 'medium') return { backgroundColor: '#facc15', color: '#111827' };
|
||||
if (level === 'low') return { backgroundColor: '#e5e7eb', color: '#111827' };
|
||||
return { backgroundColor: 'rgba(156, 163, 175, 0.35)', color: '#111827' };
|
||||
};
|
||||
|
||||
const formatCell = (value) => {
|
||||
if (value == null || value === '') return '-';
|
||||
return String(value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-table__table {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heatmap-table__th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
background-color: var(--surface-100);
|
||||
border-bottom: 2px solid var(--surface-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heatmap-table__td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.heatmap-table__tr:last-child .heatmap-table__td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.heatmap-table__tr:hover {
|
||||
background-color: var(--surface-50);
|
||||
}
|
||||
|
||||
.heatmap-table__badge {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div v-if="normalized && normalized.data.length" class="scm mt-6">
|
||||
<div v-if="showHeader" class="flex align-items-center mb-3">
|
||||
<i v-if="icon" :class="icon" class="text-primary-500 mr-2"></i>
|
||||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ title }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
|
||||
<table class="scm__table w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="scm__th">Сегмент</th>
|
||||
<th v-for="channel in normalized.channels" :key="channel" class="scm__th text-center">
|
||||
{{ channel }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in normalized.data" :key="idx" class="scm__tr">
|
||||
<td class="scm__td font-semibold">{{ getSegmentName(row) }}</td>
|
||||
<td v-for="channel in normalized.channels" :key="channel" class="scm__td text-center">
|
||||
<span class="scm__badge" :style="getCellStyle(row?.[channel])">
|
||||
{{ formatCell(row?.[channel]) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Supported shapes:
|
||||
* - Array of rows: [{ segmentName: 'Молодежь', Instagram: 'High', TikTok: 'Low', ... }, ...]
|
||||
* - Object: { channels: string[], data: Array<row> } (this is what MarketingAnalysis builds)
|
||||
*/
|
||||
matrix: { type: [Array, Object], default: null },
|
||||
title: { type: String, default: 'Матрица каналов по сегментам' },
|
||||
icon: { type: String, default: 'pi pi-th-large' },
|
||||
showHeader: { type: Boolean, default: true }
|
||||
});
|
||||
|
||||
const normalizeMatrix = (input) => {
|
||||
if (!input) return null;
|
||||
|
||||
const data = Array.isArray(input) ? input : Array.isArray(input.data) ? input.data : Array.isArray(input.matrix) ? input.matrix : [];
|
||||
if (!Array.isArray(data) || data.length === 0) return { channels: [], data: [] };
|
||||
|
||||
const channels = new Set(Array.isArray(input.channels) ? input.channels : []);
|
||||
if (channels.size === 0) {
|
||||
data.forEach((row) => {
|
||||
if (!row || typeof row !== 'object') return;
|
||||
Object.keys(row).forEach((key) => {
|
||||
if (key !== 'segmentName' && key !== 'segment' && key !== 'name') channels.add(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { channels: Array.from(channels), data };
|
||||
};
|
||||
|
||||
const normalized = computed(() => normalizeMatrix(props.matrix));
|
||||
|
||||
const getSegmentName = (row) => {
|
||||
return row?.segmentName || row?.segment || row?.name || '-';
|
||||
};
|
||||
|
||||
const normalizeLevel = (value) => {
|
||||
if (value == null || value === '') return null;
|
||||
const s = String(value).trim().toLowerCase();
|
||||
|
||||
if (s === 'high' || s.includes('высок')) return 'high';
|
||||
if (s === 'medium' || s.includes('средн')) return 'medium';
|
||||
if (s === 'low' || s.includes('низк')) return 'low';
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCellStyle = (value) => {
|
||||
const level = normalizeLevel(value);
|
||||
|
||||
// Spec colors:
|
||||
// - High: bright green
|
||||
// - Medium: yellow
|
||||
// - Low: light gray
|
||||
if (level === 'high') return { backgroundColor: '#22c55e', color: '#ffffff' };
|
||||
if (level === 'medium') return { backgroundColor: '#facc15', color: '#111827' };
|
||||
if (level === 'low') return { backgroundColor: '#e5e7eb', color: '#111827' };
|
||||
|
||||
return { backgroundColor: 'rgba(156, 163, 175, 0.35)', color: '#111827' };
|
||||
};
|
||||
|
||||
const formatCell = (value) => {
|
||||
if (value == null || value === '') return '-';
|
||||
return String(value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scm__table {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scm__th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
background-color: var(--surface-100);
|
||||
border-bottom: 2px solid var(--surface-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scm__td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.scm__tr:last-child .scm__td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.scm__tr:hover {
|
||||
background-color: var(--surface-50);
|
||||
}
|
||||
|
||||
.scm__badge {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -392,34 +392,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Segments Channel Matrix (Heatmap) -->
|
||||
<div v-if="segment.segmentsChannelMatrix" class="mt-6">
|
||||
<div class="flex align-items-center mb-3">
|
||||
<i class="pi pi-th-large 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="channel-matrix-table w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="channel-matrix-th">Сегмент</th>
|
||||
<th v-for="channel in segment.segmentsChannelMatrix.channels" :key="channel" class="channel-matrix-th text-center">
|
||||
{{ channel }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in segment.segmentsChannelMatrix.data" :key="idx" class="channel-matrix-tr">
|
||||
<td class="channel-matrix-td font-semibold">{{ row.segmentName }}</td>
|
||||
<td v-for="channel in segment.segmentsChannelMatrix.channels" :key="channel" class="channel-matrix-td text-center">
|
||||
<span class="channel-matrix-badge" :style="{ backgroundColor: getChannelMatrixColor(row[channel]) }">
|
||||
{{ row[channel] || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<SegmentsChannelMatrixHeatmap v-if="segment.segmentsChannelMatrix" :matrix="segment.segmentsChannelMatrix" />
|
||||
|
||||
<!-- Key Takeaways -->
|
||||
<div v-if="segment.keyTakeaways && segment.keyTakeaways.length > 0" class="mt-6">
|
||||
@@ -438,6 +411,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="segment.type === 'segmentsChannelMatrix'">
|
||||
<HeatmapTable :matrix="segment.matrix" :title="segment.title || 'Матрица каналов по сегментам'" />
|
||||
</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>
|
||||
@@ -783,6 +760,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SegmentsChannelMatrixHeatmap from '@/components/marketing/SegmentsChannelMatrixHeatmap.vue';
|
||||
import MarketingService from '@/service/MarketingService';
|
||||
import { marked } from 'marked';
|
||||
import markedKatex from 'marked-katex-extension';
|
||||
@@ -1812,16 +1790,6 @@ const getHeatmapColor = (value) => {
|
||||
return 'rgba(239, 68, 68, 0.9)'; // red-500
|
||||
};
|
||||
|
||||
// Get channel matrix color based on value (high/medium/low)
|
||||
const getChannelMatrixColor = (value) => {
|
||||
if (!value) return 'rgba(156, 163, 175, 0.5)'; // gray
|
||||
const normalizedValue = String(value).toLowerCase();
|
||||
if (normalizedValue === 'high' || normalizedValue === 'высокий' || normalizedValue === 'высокая') return 'rgba(16, 185, 129, 0.9)'; // green
|
||||
if (normalizedValue === 'medium' || normalizedValue === 'средний' || normalizedValue === 'средняя') return 'rgba(245, 158, 11, 0.9)'; // amber
|
||||
if (normalizedValue === 'low' || normalizedValue === 'низкий' || normalizedValue === 'низкая') return 'rgba(239, 68, 68, 0.9)'; // red
|
||||
return 'rgba(156, 163, 175, 0.5)'; // gray
|
||||
};
|
||||
|
||||
// Helper: detect Chart.js "data" shape (labels + datasets)
|
||||
const isChartJsData = (value) => {
|
||||
return !!(value && typeof value === 'object' && !Array.isArray(value) && Array.isArray(value.labels) && Array.isArray(value.datasets));
|
||||
@@ -2272,6 +2240,15 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
|
||||
}
|
||||
};
|
||||
}
|
||||
case 'segmentschannelmatrix': {
|
||||
const matrix = buildSegmentsChannelMatrix(data);
|
||||
if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) return null;
|
||||
return {
|
||||
type: 'segmentsChannelMatrix',
|
||||
title: 'Матрица каналов по сегментам',
|
||||
matrix
|
||||
};
|
||||
}
|
||||
default:
|
||||
// Unknown block type: render raw markdown so nothing is lost
|
||||
return {
|
||||
@@ -3492,49 +3469,6 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Channel Matrix Table Styles */
|
||||
.channel-matrix-table {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-matrix-th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
background-color: var(--surface-100);
|
||||
border-bottom: 2px solid var(--surface-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-matrix-td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.channel-matrix-tr:last-child .channel-matrix-td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.channel-matrix-tr:hover {
|
||||
background-color: var(--surface-50);
|
||||
}
|
||||
|
||||
.channel-matrix-badge {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Competitors Heatmap Table Styles */
|
||||
.competitors-heatmap-table {
|
||||
border: 1px solid var(--surface-border);
|
||||
|
||||
Reference in New Issue
Block a user