.
This commit is contained in:
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user