b6b8d6e0d6
Воронка подсвечивает красным шаг, где теряем больше всего людей. Гости с VPN и хостингов показаны отдельным числом, а не подмешаны в конверсию. Город при VPN честно помечается как недостоверный. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
261 lines
11 KiB
Vue
261 lines
11 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Админка → «Посетители». Свой счётчик: роботы в базу не попадают вовсе
|
||
* (они не исполняют скрипты страницы), гости с хостингов и VPN считаются отдельно.
|
||
* Метрику и Вебвизор не заменяет — работает параллельно.
|
||
* Spec: docs/superpowers/specs/2026-07-13-visitors-analytics-design.md
|
||
*/
|
||
import { computed, onMounted, ref } from 'vue';
|
||
import {
|
||
getChannels,
|
||
getFunnel,
|
||
getPortalActivity,
|
||
getVisits,
|
||
type ChannelRow,
|
||
type Funnel,
|
||
type Period,
|
||
type PortalActivity,
|
||
type VisitRow,
|
||
} from '../../api/adminVisitors';
|
||
|
||
const period = ref<Period>('7d');
|
||
const loading = ref(false);
|
||
const fetchError = ref(false);
|
||
const funnel = ref<Funnel | null>(null);
|
||
const channels = ref<ChannelRow[]>([]);
|
||
const visits = ref<VisitRow[]>([]);
|
||
const portal = ref<PortalActivity | null>(null);
|
||
|
||
const PERIODS: Array<{ value: Period; label: string }> = [
|
||
{ value: 'today', label: 'Сегодня' },
|
||
{ value: '7d', label: '7 дней' },
|
||
{ value: '30d', label: '30 дней' },
|
||
];
|
||
|
||
const CHANNEL_NAMES: Record<string, string> = {
|
||
sms: 'Смс-рассылка',
|
||
hh: 'Вакансия на hh.ru',
|
||
search: 'Поиск (Яндекс, Google)',
|
||
referral: 'Ссылки с других сайтов',
|
||
direct: 'Прямые заходы',
|
||
tg: 'Телеграм',
|
||
};
|
||
const channelName = (c: string): string => CHANNEL_NAMES[c] ?? c;
|
||
|
||
const STEPS: Array<{ key: keyof Funnel['funnel']; label: string }> = [
|
||
{ key: 'view', label: 'Открыли сайт' },
|
||
{ key: 'alive', label: 'Живые (не робот)' },
|
||
{ key: 'cta_click', label: 'Нажали кнопку' },
|
||
{ key: 'register_open', label: 'Открыли регистрацию' },
|
||
{ key: 'register_done', label: 'Зарегистрировались' },
|
||
{ key: 'login_done', label: 'Вошли в кабинет' },
|
||
];
|
||
|
||
/** Шаги воронки с процентом перехода от предыдущего шага. */
|
||
const steps = computed(() => {
|
||
const f = funnel.value?.funnel;
|
||
if (!f) return [];
|
||
|
||
return STEPS.map((s, i) => {
|
||
const prev = i === 0 ? f[STEPS[0].key] : f[STEPS[i - 1].key];
|
||
const value = f[s.key];
|
||
const pct = i === 0 || prev === 0 ? null : Math.round((value / prev) * 100);
|
||
|
||
return { ...s, value, pct };
|
||
});
|
||
});
|
||
|
||
/** Шаг с самым большим отвалом — его подсвечиваем красным: там теряем людей. */
|
||
const worstIndex = computed(() => {
|
||
const withPct = steps.value.filter((s) => s.pct !== null);
|
||
if (withPct.length === 0) return -1;
|
||
const worst = withPct.reduce((a, b) => ((a.pct ?? 100) <= (b.pct ?? 100) ? a : b));
|
||
|
||
return steps.value.findIndex((s) => s.key === worst.key);
|
||
});
|
||
|
||
const nobody = computed(() => (funnel.value?.funnel.view ?? 0) === 0);
|
||
|
||
const deviceLabel = (d: string): string =>
|
||
d === 'phone' ? 'Телефон' : d === 'tablet' ? 'Планшет' : 'Компьютер';
|
||
|
||
/** Через VPN/хостинг город врёт — так и пишем, а не подсовываем липовый «Амстердам». */
|
||
const placeLabel = (v: VisitRow): string =>
|
||
v.is_datacenter ? 'через VPN — город недостоверен' : (v.city ?? 'город не определён');
|
||
|
||
const STEP_NAMES: Record<string, string> = {
|
||
view: 'открыл сайт',
|
||
alive: 'читал',
|
||
cta_click: 'нажал кнопку',
|
||
register_open: 'открыл регистрацию',
|
||
register_done: 'зарегистрировался',
|
||
login_done: 'вошёл',
|
||
portal_screen: 'ходил по кабинету',
|
||
};
|
||
const stepsLabel = (v: VisitRow): string =>
|
||
v.steps.map((s) => STEP_NAMES[s] ?? s).join(' → ') || '—';
|
||
|
||
const conversion = (c: ChannelRow): number =>
|
||
c.people === 0 ? 0 : Math.round((c.registrations / c.people) * 100);
|
||
|
||
const dt = (iso: string): string => new Date(iso).toLocaleString('ru-RU');
|
||
|
||
async function load(): Promise<void> {
|
||
loading.value = true;
|
||
fetchError.value = false;
|
||
try {
|
||
const [f, c, v, p] = await Promise.all([
|
||
getFunnel(period.value),
|
||
getChannels(period.value),
|
||
getVisits(period.value),
|
||
getPortalActivity(period.value),
|
||
]);
|
||
funnel.value = f;
|
||
channels.value = c;
|
||
visits.value = v;
|
||
portal.value = p;
|
||
} catch {
|
||
fetchError.value = true;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(load);
|
||
</script>
|
||
|
||
<template>
|
||
<v-container fluid>
|
||
<div class="d-flex align-center flex-wrap ga-3 mb-4">
|
||
<v-btn-toggle v-model="period" mandatory density="comfortable" @update:model-value="load">
|
||
<v-btn v-for="p in PERIODS" :key="p.value" :value="p.value">{{ p.label }}</v-btn>
|
||
</v-btn-toggle>
|
||
<v-progress-circular v-if="loading" indeterminate size="22" />
|
||
</div>
|
||
|
||
<v-alert v-if="fetchError" type="error" variant="tonal" class="mb-4">
|
||
Не удалось загрузить данные. Обновите страницу.
|
||
</v-alert>
|
||
|
||
<!-- Воронка: где теряем людей -->
|
||
<v-card class="mb-4">
|
||
<v-card-title>Путь посетителя</v-card-title>
|
||
<v-card-subtitle>
|
||
Считаются те, кто впервые пришёл за выбранный период. Роботы не учитываются вовсе —
|
||
они не исполняют скрипты страницы.
|
||
</v-card-subtitle>
|
||
<v-card-text>
|
||
<div v-if="nobody" class="text-medium-emphasis py-4">
|
||
Пока никто не заходил.
|
||
</div>
|
||
<div v-else class="d-flex flex-wrap ga-2">
|
||
<v-sheet
|
||
v-for="(s, i) in steps"
|
||
:key="s.key"
|
||
class="pa-4 flex-grow-1 text-center rounded"
|
||
:color="i === worstIndex ? 'error' : 'surface-variant'"
|
||
:theme="i === worstIndex ? 'dark' : undefined"
|
||
min-width="140"
|
||
>
|
||
<div class="text-h4">{{ s.value }}</div>
|
||
<div class="text-body-2 mt-1">{{ s.label }}</div>
|
||
<div v-if="s.pct !== null" class="text-caption mt-1">{{ s.pct }}% от предыдущего</div>
|
||
</v-sheet>
|
||
</div>
|
||
|
||
<v-alert v-if="funnel" type="info" variant="tonal" density="compact" class="mt-4">
|
||
Гостей с хостингов и VPN за период: {{ funnel.vpn_or_hosting }} — они посчитаны отдельно
|
||
и в воронку не входят: это либо маскирующиеся роботы, либо люди, у которых город определить нельзя.
|
||
</v-alert>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- Каналы: что приносит клиентов, а что только клики -->
|
||
<v-card class="mb-4">
|
||
<v-card-title>Откуда приходят</v-card-title>
|
||
<v-table v-if="channels.length">
|
||
<thead>
|
||
<tr>
|
||
<th>Канал</th>
|
||
<th class="text-right">Людей</th>
|
||
<th class="text-right">Нажали кнопку</th>
|
||
<th class="text-right">Зарегистрировались</th>
|
||
<th class="text-right">Вошли</th>
|
||
<th class="text-right">Конверсия в клиента</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="c in channels" :key="c.channel">
|
||
<td>{{ channelName(c.channel) }}</td>
|
||
<td class="text-right">{{ c.people }}</td>
|
||
<td class="text-right">{{ c.cta_clicks }}</td>
|
||
<td class="text-right">{{ c.registrations }}</td>
|
||
<td class="text-right">{{ c.logins }}</td>
|
||
<td class="text-right">{{ conversion(c) }}%</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
<v-card-text v-else class="text-medium-emphasis">
|
||
Пока никто не заходил.
|
||
</v-card-text>
|
||
<v-card-text class="text-caption text-medium-emphasis pt-0">
|
||
«Прямые заходы» — это в том числе переходы из смс и мессенджеров: они источник не передают.
|
||
Чтобы отделить их, давайте в рассылке ссылку с меткой, например
|
||
<code>liderra.ru/?utm_source=sms</code>.
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- Поштучно: кто был и что успел сделать -->
|
||
<v-card class="mb-4">
|
||
<v-card-title>Последние визиты</v-card-title>
|
||
<v-table v-if="visits.length">
|
||
<thead>
|
||
<tr>
|
||
<th>Когда</th>
|
||
<th>Канал</th>
|
||
<th>Устройство</th>
|
||
<th>Место</th>
|
||
<th>Что делал</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="v in visits" :key="v.id">
|
||
<td class="text-no-wrap">{{ dt(v.first_seen_at) }}</td>
|
||
<td>{{ channelName(v.channel) }}</td>
|
||
<td>{{ deviceLabel(v.device) }}</td>
|
||
<td>{{ placeLabel(v) }}</td>
|
||
<td>{{ stepsLabel(v) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
<v-card-text v-else class="text-medium-emphasis">
|
||
Пока никто не заходил.
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- Что делают внутри кабинета -->
|
||
<v-card>
|
||
<v-card-title>Что смотрят в кабинете</v-card-title>
|
||
<v-card-text v-if="!portal || portal.screens.length === 0" class="text-medium-emphasis">
|
||
За период в кабинет никто не заходил.
|
||
</v-card-text>
|
||
<v-table v-else>
|
||
<thead>
|
||
<tr>
|
||
<th>Экран</th>
|
||
<th class="text-right">Открытий</th>
|
||
<th class="text-right">Людей</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="s in portal.screens" :key="s.screen">
|
||
<td>{{ s.screen }}</td>
|
||
<td class="text-right">{{ s.opens }}</td>
|
||
<td class="text-right">{{ s.people }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
</v-card>
|
||
</v-container>
|
||
</template>
|