.
This commit is contained in:
@@ -588,14 +588,17 @@
|
|||||||
<div class="col-span-12">
|
<div class="col-span-12">
|
||||||
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
|
<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="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
|
<i
|
||||||
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
|
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
|
||||||
v-tooltip.top="'Процентное распределение трафика/бюджета по источникам (SEO, Instagram, Контекст). Показывает, какие каналы приносят больше всего клиентов.'"
|
v-tooltip.top="'Стоимость привлечения клиента по каждому каналу. Показывает, какие каналы наиболее эффективны с точки зрения стоимости привлечения.'"
|
||||||
></i>
|
></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-[280px]">
|
<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>
|
</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 channelEfficiencyRows = computed(() => {
|
||||||
const eff = report.value?.sections?.acquisition_channels?.channel_efficiency ?? {};
|
const eff = report.value?.sections?.acquisition_channels?.channel_efficiency ?? {};
|
||||||
const order = [
|
|
||||||
{ key: 'yandex', label: 'Яндекс' },
|
// Маппинг ключей каналов на читаемые названия
|
||||||
{ key: 'seo', label: 'SEO (сиб.тайт)' },
|
const channelLabels = {
|
||||||
{ key: 'retargeting', label: 'Ретаргет' },
|
'yandex': 'Яндекс',
|
||||||
{ key: 'vkontakte', label: 'VKontakte' },
|
'yandex_direct': 'Яндекс Директ',
|
||||||
{ key: 'youtube', label: 'YouTube' },
|
'seo': 'SEO',
|
||||||
{ key: 'instagram', label: 'Instagram' }
|
'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) {
|
function statusClass(status) {
|
||||||
const s = String(status || '').toLowerCase();
|
const s = String(status || '').toLowerCase();
|
||||||
if (s.includes('эффектив')) return 'text-green-700';
|
// Обработка английских статусов
|
||||||
if (s.includes('удовлетвор')) return 'text-amber-700';
|
if (s.includes('very high') || s.includes('очень высок') || s.includes('высок')) return 'text-green-700 font-semibold';
|
||||||
if (s.includes('завыш')) return 'text-red-700';
|
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 'text-slate-600';
|
||||||
}
|
}
|
||||||
|
|
||||||
return order
|
// Получаем все каналы из данных и преобразуем в массив
|
||||||
.map((o) => {
|
return Object.keys(eff)
|
||||||
const row = eff[o.key];
|
.map((key) => {
|
||||||
|
const row = eff[key];
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
key: o.key,
|
key: key,
|
||||||
label: o.label,
|
label: getChannelLabel(key),
|
||||||
cost: row.cost ?? '—',
|
cost: row.cost ?? '—',
|
||||||
status: row.status ?? '—',
|
status: row.status ?? '—',
|
||||||
statusClass: statusClass(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
|
// Economics tables
|
||||||
|
|||||||
@@ -110,6 +110,13 @@
|
|||||||
<small v-if="errors.weakSide" class="p-error">{{ errors.weakSide }}</small>
|
<small v-if="errors.weakSide" class="p-error">{{ errors.weakSide }}</small>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label for="analysisType" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Тип анализа * </label>
|
<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 }">
|
<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 Button from 'primevue/button';
|
||||||
import Chart from 'primevue/chart';
|
import Chart from 'primevue/chart';
|
||||||
import Checkbox from 'primevue/checkbox';
|
import Checkbox from 'primevue/checkbox';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import MultiSelect from 'primevue/multiselect';
|
import MultiSelect from 'primevue/multiselect';
|
||||||
import ProgressSpinner from 'primevue/progressspinner';
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
@@ -862,6 +870,7 @@ const formData = ref({
|
|||||||
detailLevel: 'СТАНДАРТНО',
|
detailLevel: 'СТАНДАРТНО',
|
||||||
strongSide: '',
|
strongSide: '',
|
||||||
weakSide: '',
|
weakSide: '',
|
||||||
|
marketingBudget: null,
|
||||||
analysisType: []
|
analysisType: []
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1313,6 +1322,9 @@ const handleSubmit = async () => {
|
|||||||
if (formData.value.weakSide && formData.value.weakSide.trim()) {
|
if (formData.value.weakSide && formData.value.weakSide.trim()) {
|
||||||
requestData.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 });
|
const result = await MarketingService.startAnalysis(requestData, { generateV2: true });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user