This commit is contained in:
root
2026-01-13 01:46:19 +05:00
parent a60c128bce
commit f7626d77b7
+136 -3
View File
@@ -453,7 +453,19 @@
></i>
</div>
<div class="h-[260px]">
<Chart type="doughnut" :data="queryTypesDonutData" :options="compactDonutOptions" />
<Chart type="doughnut" :data="queryTypesDonutData" :options="queryTypesDonutOptions" />
</div>
<!-- Метрики под диаграммой -->
<div v-if="queryTypesMetrics.length > 0" class="mt-4 pt-4 border-t border-slate-200/70">
<div class="grid grid-cols-2 gap-3">
<div v-for="metric in queryTypesMetrics" :key="metric.label" class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full" :style="{ background: metric.color }"></div>
<div class="flex-1 min-w-0">
<div class="text-[12px] text-slate-600 truncate">{{ metric.label }}</div>
</div>
<div class="text-[13px] font-semibold text-slate-800 whitespace-nowrap">{{ metric.value }}%</div>
</div>
</div>
</div>
</div>
</div>
@@ -1229,6 +1241,23 @@ function parsePercent(value) {
const ageRows = computed(() => {
const a = report.value?.sections?.target_audience?.age_structure ?? {};
// Маппинг ключей бэкенда на ключи фронтенда
// Если ключ бэкенда пересекается с несколькими группами, распределяем значение
const backendKeyMap = {
'18-24': { '18-24': 1.0 },
'18-25': { '18-24': 1.0 }, // Маппим на 18-24
'25-34': { '25-34': 1.0 },
'25-45': { '25-34': 0.5, '35-44': 0.5 }, // Распределяем поровну
'35-44': { '35-44': 1.0 },
'20-40': { '25-34': 0.5, '35-44': 0.5 }, // Распределяем поровну
'45-54': { '45-54': 1.0 },
'40-60': { '45-54': 1.0 }, // Маппим на 45-54
'55+': { '55+': 1.0 },
'60+': { '55+': 1.0 },
'60_plus': { '55+': 1.0 }
};
// Стандартный порядок для отображения
const order = [
{ key: '18-24', label: '1824' },
{ key: '25-34', label: '2534' },
@@ -1237,9 +1266,28 @@ const ageRows = computed(() => {
{ key: '55+', label: '55+' }
];
// Агрегируем значения из бэкенда
const aggregated = {};
Object.entries(a).forEach(([backendKey, value]) => {
const mapping = backendKeyMap[backendKey];
if (mapping) {
const parsedValue = parsePercent(value);
if (Number.isFinite(parsedValue)) {
Object.entries(mapping).forEach(([frontendKey, ratio]) => {
aggregated[frontendKey] = (aggregated[frontendKey] || 0) + parsedValue * ratio;
});
}
}
});
return order.map((o) => {
const v = parsePercent(a?.[o.key]);
return { key: o.key, label: o.label, value: Number.isFinite(v) ? Math.round(v) : 0 };
const v = aggregated[o.key] || 0;
return {
key: o.key,
label: o.label,
value: Number.isFinite(v) ? Math.round(v) : 0
};
});
});
@@ -1260,6 +1308,75 @@ const compactDonutOptions = computed(() => ({
cutout: '62%'
}));
// Опции для donut диаграммы с отображением процентов
const queryTypesDonutOptions = computed(() => {
return {
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { usePointStyle: true, color: '#334155', boxWidth: 8 } },
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
return `${label}: ${percentage}%`;
}
}
},
// Кастомный плагин для отображения процентов на сегментах
donutPercent: {
id: 'donutPercent',
afterDatasetsDraw(chart) {
const ctx = chart.ctx;
const data = chart.data.datasets[0]?.data || [];
const total = data.reduce((a, b) => a + b, 0);
if (total === 0) return;
chart.data.datasets.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i);
meta.data.forEach((element, index) => {
const value = dataset.data[index];
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
// Пропускаем отображение, если процент слишком мал
if (parseFloat(percentage) < 3) return;
// Получаем центр сегмента (для donut диаграммы используем центр дуги)
const arc = element;
const { x, y, startAngle, endAngle, innerRadius, outerRadius } = arc;
// Вычисляем средний угол для размещения текста
const angle = (startAngle + endAngle) / 2;
// Размещаем текст на середине между innerRadius и outerRadius
const radius = (innerRadius + outerRadius) / 2;
// Вычисляем координаты центра сегмента
const centerX = x + Math.cos(angle) * radius;
const centerY = y + Math.sin(angle) * radius;
ctx.save();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 13px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0, 0, 0, 0.4)';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.fillText(`${percentage}%`, centerX, centerY);
ctx.restore();
});
});
}
}
},
cutout: '62%'
};
});
const ageBarData = computed(() => ({
labels: ageRows.value.map((r) => r.label),
datasets: [
@@ -1495,6 +1612,22 @@ const queryTypesDonutData = computed(() => {
};
});
// Метрики для отображения под диаграммой
const queryTypesMetrics = computed(() => {
const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {};
const labels = ['Прямой интерес', 'Поиск по вопросу', 'Конкуренты', 'Решение проблемы'];
const values = [q.direct_interest_catalog, q.question_based_articles, q.competitors_reviews, q.problem_solving_hot_promo].map(percentToNumber);
const colors = [PPT_BLUE, '#7C3AED', PPT_ORANGE, '#34D399'];
return labels
.map((label, index) => ({
label,
value: values[index] || 0,
color: colors[index]
}))
.filter((metric) => metric.value > 0);
});
const decisionFactorsBarData = computed(() => {
const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {};
const labels = ['Акция/скидка', 'Отзыв/рекоменд.', 'Гарантия/качество', 'Удобство/сервис', 'Наличие мест', 'Цена'];