This commit is contained in:
root
2025-12-20 23:25:02 +05:00
parent 6a7556df2f
commit e186e36a74
3 changed files with 297 additions and 81 deletions
+138
View File
@@ -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>
+15 -81
View File
@@ -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);