Files
portal/app/resources/js/views/admin/AdminVisitorsView.vue
T
Дмитрий b6b8d6e0d6 feat(visitors): раздел «Посетители» в админке — воронка, каналы, визиты, кабинет
Воронка подсвечивает красным шаг, где теряем больше всего людей. Гости с VPN
и хостингов показаны отдельным числом, а не подмешаны в конверсию. Город при
VPN честно помечается как недостоверный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:03:38 +03:00

261 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>