This commit is contained in:
root
2026-01-22 21:41:16 +05:00
parent 6e37ab7916
commit cfd306866b
2 changed files with 173 additions and 20 deletions
+161 -20
View File
@@ -588,14 +588,17 @@
<div class="col-span-12">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Доли каналов</div>
<div class="text-[16px] font-semibold text-slate-800">Эффективность каналов</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Процентное распределение трафика/бюджета по источникам (SEO, Instagram, Контекст). Показывает, какие каналы приносят больше всего клиентов.'"
v-tooltip.top="'Стоимость привлечения клиента по каждому каналу. Показывает, какие каналы наиболее эффективны с точки зрения стоимости привлечения.'"
></i>
</div>
<div class="h-[280px]">
<Chart type="bar" :data="channelSharesBarData" :options="channelSharesBarOptions" :plugins="barPlugins" />
<Chart v-if="channelEfficiencyBarData.labels.length > 0" type="bar" :data="channelEfficiencyBarData" :options="channelEfficiencyBarOptions" :plugins="barPlugins" />
<div v-else class="h-full flex items-center justify-center text-slate-500 text-[13px]">
Нет данных об эффективности каналов
</div>
</div>
</div>
</div>
@@ -1943,38 +1946,176 @@ const channelSharesBarOptions = computed(() => {
};
});
// Функция для извлечения числового значения из стоимости (например, "2500 KZT" -> 2500)
function extractCostNumber(costStr) {
if (!costStr || costStr === '—') return 0;
const num = Number(String(costStr).replace(/[^\d.]/g, ''));
return Number.isFinite(num) ? num : 0;
}
// График эффективности каналов (стоимость привлечения)
const channelEfficiencyBarData = computed(() => {
const eff = report.value?.sections?.acquisition_channels?.channel_efficiency ?? {};
// Маппинг ключей каналов на читаемые названия
const channelLabels = {
'yandex': 'Яндекс',
'yandex_direct': 'Яндекс Директ',
'seo': 'SEO',
'retargeting': 'Ретаргет',
'vkontakte': 'VKontakte',
'youtube': 'YouTube',
'instagram': 'Instagram',
'tiktok': 'TikTok',
'kaspi': 'Kaspi',
'google_search': 'Google Поиск',
'2gis': '2GIS',
'google_ads': 'Google Ads',
'facebook': 'Facebook'
};
function getChannelLabel(key) {
return channelLabels[key] || key.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
}
// Цвета для разных каналов
const channelColors = [
PPT_BLUE,
PPT_ORANGE,
PPT_GREEN,
'#60A5FA',
'#A855F7',
'#EC4899',
'#F59E0B',
'#10B981',
'#3B82F6',
'#8B5CF6'
];
// Получаем все каналы из данных
const items = Object.keys(eff)
.map((key, index) => {
const row = eff[key];
if (!row || !row.cost) return null;
return {
key: key,
label: getChannelLabel(key),
cost: row.cost,
costValue: extractCostNumber(row.cost),
color: channelColors[index % channelColors.length]
};
})
.filter(Boolean)
.sort((a, b) => a.costValue - b.costValue); // Сортируем по стоимости (от меньшей к большей)
return {
labels: items.map((i) => i.label),
datasets: [
{
data: items.map((i) => i.costValue),
borderRadius: 12,
borderSkipped: false,
backgroundColor: (context) => {
const { chart, dataIndex } = context;
const { ctx, chartArea } = chart;
const base = items[dataIndex]?.color ?? PPT_BLUE;
if (!chartArea) return hexToRgba(base, 0.55);
return makeVerticalGradient(ctx, chartArea, hexToRgba(base, 0.88), hexToRgba(base, 0.24));
}
}
]
};
});
const channelEfficiencyBarOptions = computed(() => {
const base = compactBarOptions.value;
return {
...base,
indexAxis: 'y',
scales: {
x: {
...base.scales.x,
grid: { color: 'rgba(148,163,184,0.15)', drawBorder: false },
ticks: {
callback: function(value) {
return value.toLocaleString('ru-RU') + ' KZT';
}
}
},
y: { ...base.scales.y, grid: { display: false, drawBorder: false } }
},
plugins: {
...base.plugins,
tooltip: {
...base.plugins?.tooltip,
callbacks: {
label: function(context) {
return context.parsed.x.toLocaleString('ru-RU') + ' KZT';
}
}
}
}
};
});
const channelEfficiencyRows = computed(() => {
const eff = report.value?.sections?.acquisition_channels?.channel_efficiency ?? {};
const order = [
{ key: 'yandex', label: 'Яндекс' },
{ key: 'seo', label: 'SEO (сиб.тайт)' },
{ key: 'retargeting', label: 'Ретаргет' },
{ key: 'vkontakte', label: 'VKontakte' },
{ key: 'youtube', label: 'YouTube' },
{ key: 'instagram', label: 'Instagram' }
];
// Маппинг ключей каналов на читаемые названия
const channelLabels = {
'yandex': 'Яндекс',
'yandex_direct': 'Яндекс Директ',
'seo': 'SEO',
'retargeting': 'Ретаргет',
'vkontakte': 'VKontakte',
'youtube': 'YouTube',
'instagram': 'Instagram',
'tiktok': 'TikTok',
'kaspi': 'Kaspi',
'google_search': 'Google Поиск',
'2gis': '2GIS',
'google_ads': 'Google Ads',
'facebook': 'Facebook'
};
function getChannelLabel(key) {
return channelLabels[key] || key.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
}
function statusClass(status) {
const s = String(status || '').toLowerCase();
if (s.includes('эффектив')) return 'text-green-700';
if (s.includes('удовлетвор')) return 'text-amber-700';
if (s.includes('завыш')) return 'text-red-700';
// Обработка английских статусов
if (s.includes('very high') || s.includes('очень высок') || s.includes('высок')) return 'text-green-700 font-semibold';
if (s.includes('high') || s.includes('эффектив')) return 'text-green-700';
if (s.includes('medium') || s.includes('средн') || s.includes('удовлетвор')) return 'text-amber-700';
if (s.includes('low') || s.includes('низк') || s.includes('завыш')) return 'text-red-700';
if (s.includes('local') || s.includes('локальн')) return 'text-blue-700';
return 'text-slate-600';
}
return order
.map((o) => {
const row = eff[o.key];
// Получаем все каналы из данных и преобразуем в массив
return Object.keys(eff)
.map((key) => {
const row = eff[key];
if (!row) return null;
return {
key: o.key,
label: o.label,
key: key,
label: getChannelLabel(key),
cost: row.cost ?? '—',
status: row.status ?? '—',
statusClass: statusClass(row.status)
};
})
.filter(Boolean);
.filter(Boolean)
.sort((a, b) => {
// Сортируем по статусу: Very High -> High -> Medium -> Local -> остальные
const statusOrder = { 'very high': 1, 'high': 2, 'medium': 3, 'local': 4 };
const aStatus = String(a.status || '').toLowerCase();
const bStatus = String(b.status || '').toLowerCase();
const aOrder = statusOrder[aStatus] || 5;
const bOrder = statusOrder[bStatus] || 5;
return aOrder - bOrder;
});
});
// Economics tables
@@ -110,6 +110,13 @@
<small v-if="errors.weakSide" class="p-error">{{ errors.weakSide }}</small>
</div>
<div class="field">
<label for="marketingBudget" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Бюджет на маркетинг </label>
<InputNumber id="marketingBudget" v-model="formData.marketingBudget" :min="0" placeholder="Введите бюджет в тенге" class="w-full" :class="{ 'p-invalid': errors.marketingBudget }" />
<small v-if="errors.marketingBudget" class="p-error">{{ errors.marketingBudget }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Укажите ваш бюджет на маркетинг (опционально)</small>
</div>
<div class="field">
<label for="analysisType" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Тип анализа * </label>
<MultiSelect id="analysisType" v-model="formData.analysisType" :options="VALID_ANALYSIS_TYPES" placeholder="Выберите типы анализа" class="w-full" display="chip" :class="{ 'p-invalid': errors.analysisType }">
@@ -800,6 +807,7 @@ import markedKatex from 'marked-katex-extension';
import Button from 'primevue/button';
import Chart from 'primevue/chart';
import Checkbox from 'primevue/checkbox';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import ProgressSpinner from 'primevue/progressspinner';
@@ -862,6 +870,7 @@ const formData = ref({
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
marketingBudget: null,
analysisType: []
});
@@ -1313,6 +1322,9 @@ const handleSubmit = async () => {
if (formData.value.weakSide && formData.value.weakSide.trim()) {
requestData.weakSide = formData.value.weakSide.trim();
}
if (formData.value.marketingBudget !== null && formData.value.marketingBudget !== undefined) {
requestData.marketing_budget = formData.value.marketingBudget;
}
const result = await MarketingService.startAnalysis(requestData, { generateV2: true });