feat(analytics): add site analytics with grouped navigation sections

- Add Analytics container: models, tasks, jobs, controllers, routes, services
- Add analytics config with section groups (structure, institute, content, services)
- Add database tables: analytics_sessions, analytics_hits, analytics_daily_stats
- Add frontend: Analytics page, section detail, chart, overview, navigation components
- Add consent banner and JS tracking service (sendBeacon + Inertia listener)
- Add permission sync for view_analytics, view_any_analytic
- Fix trailing slash URL matching in GetNavigationSectionsTask
- Fix event propagation on expand arrows in navigation sections
- Fix RecordVisitJob to not depend on dropped analytics_geo_cache table
- Replace plain text views/visitors with badge-style icons
- Group navigation sections by category with separate blocks
- Add config-driven non-CMS sections (faculties, divisions, DPO, journals)
- Remove Events, TV, Sveden from analytics sections
This commit is contained in:
F4ilji
2026-09-18 22:18:04 +05:00
parent 8af94388ab
commit d6c0f48d48
60 changed files with 2948 additions and 121 deletions
@@ -0,0 +1,145 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-foreground">Посещаемость</h3>
<div class="flex bg-layer border border-layer-line rounded-md p-0.5">
<button
@click="metric = 'visitors'"
class="px-2.5 py-1 text-[11px] font-medium rounded transition-colors"
:class="metric === 'visitors' ? 'bg-primary text-white' : 'text-muted-foreground-1 hover:text-foreground'"
>
Посетители
</button>
<button
@click="metric = 'views'"
class="px-2.5 py-1 text-[11px] font-medium rounded transition-colors"
:class="metric === 'views' ? 'bg-primary text-white' : 'text-muted-foreground-1 hover:text-foreground'"
>
Просмотры
</button>
</div>
</div>
<div v-if="data.length === 0" class="text-xs text-muted-foreground-1 py-8 text-center">
Нет данных за выбранный период
</div>
<div v-else-if="data.length === 1" class="py-8 text-center">
<p class="text-3xl font-bold text-foreground">{{ data[0][metric === 'views' ? 'views' : 'visitors'] }}</p>
<p class="text-xs text-muted-foreground-1 mt-1">{{ formatDate(data[0].date) }}</p>
</div>
<div v-else class="relative" style="height: 200px;">
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script>
import { Line } from 'vue-chartjs';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip, Legend);
export default {
name: 'AnalyticsChart',
components: { Line },
props: {
data: { type: Array, required: true },
},
data() {
return { metric: 'visitors' };
},
computed: {
chartData() {
const values = this.data.map(d => this.metric === 'views' ? d.views : d.visitors);
return {
labels: this.data.map(d => this.formatDate(d.date)),
datasets: [
{
label: this.metric === 'views' ? 'Просмотры' : 'Посетители',
data: values,
borderColor: 'rgb(30, 87, 163)',
backgroundColor: 'rgba(30, 87, 163, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: this.data.length > 31 ? 0 : 3,
pointHoverRadius: 5,
pointBackgroundColor: 'white',
pointBorderColor: 'rgb(30, 87, 163)',
pointBorderWidth: 2,
},
],
};
},
chartOptions() {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgb(30, 30, 30)',
titleFont: { size: 11 },
bodyFont: { size: 12 },
padding: 8,
cornerRadius: 6,
displayColors: false,
callbacks: {
title: (items) => {
const idx = items[0].dataIndex;
const d = new Date(this.data[idx].date);
const days = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
return `${d.getDate()}.${d.getMonth() + 1} (${days[d.getDay()]})`;
},
label: (item) => `${this.metric === 'views' ? 'Просмотры' : 'Посетители'}: ${item.formattedValue}`,
},
},
},
scales: {
x: {
grid: { display: false },
ticks: {
font: { size: 10 },
color: '#9ca3af',
maxTicksLimit: this.data.length > 31 ? 10 : 15,
},
},
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.05)' },
ticks: {
font: { size: 10 },
color: '#9ca3af',
precision: 0,
},
},
},
};
},
},
methods: {
formatDate(dateStr) {
const d = new Date(dateStr);
return `${d.getDate()}.${d.getMonth() + 1}`;
},
},
watch: {
metric() {},
},
};
</script>
@@ -0,0 +1,117 @@
<template>
<div>
<div class="flex gap-1 mb-4 bg-layer border border-layer-line rounded-md p-0.5">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1 text-[11px] font-medium rounded transition-colors"
:class="activeTab === tab.key ? 'bg-primary text-white' : 'text-muted-foreground-1 hover:text-foreground'"
>
{{ tab.label }}
</button>
</div>
<div v-if="activeTab === 'devices'">
<div v-if="Object.keys(devices).length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-3">
<div v-for="(count, type) in devices" :key="type" class="flex items-center justify-between">
<div class="flex items-center gap-2">
<DashboardIcon :name="deviceIcon(type)" size="5" class="text-muted-foreground-2" />
<span class="text-xs font-medium text-foreground">{{ deviceLabel(type) }}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div class="h-full bg-primary/60 rounded-full" :style="{ width: getDevicePercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-12 text-right">{{ getDevicePercent(count) }}%</span>
</div>
</div>
</div>
</div>
<div v-else-if="activeTab === 'browsers'">
<div v-if="Object.keys(browsers).length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-2">
<div v-for="(count, name) in browsers" :key="name" class="flex items-center justify-between">
<span class="text-xs font-medium text-foreground">{{ name }}</span>
<div class="flex items-center gap-2">
<div class="w-20 h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div class="h-full bg-primary/60 rounded-full" :style="{ width: getBrowserPercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-8 text-right">{{ count }}</span>
</div>
</div>
</div>
</div>
<div v-else>
<div v-if="Object.keys(oses).length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-2">
<div v-for="(count, name) in oses" :key="name" class="flex items-center justify-between">
<span class="text-xs font-medium text-foreground">{{ name }}</span>
<div class="flex items-center gap-2">
<div class="w-20 h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div class="h-full bg-primary/60 rounded-full" :style="{ width: getOsPercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-8 text-right">{{ count }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'AnalyticsDevices',
components: { DashboardIcon },
props: {
devices: { type: Object, required: true },
browsers: { type: Object, required: true },
oses: { type: Object, required: true },
},
data() {
return {
activeTab: 'devices',
tabs: [
{ key: 'devices', label: 'Устройства' },
{ key: 'browsers', label: 'Браузеры' },
{ key: 'oses', label: 'ОС' },
],
};
},
computed: {
totalDevices() {
return Object.values(this.devices).reduce((a, b) => a + b, 0) || 1;
},
maxBrowser() {
return Math.max(...Object.values(this.browsers), 1);
},
maxOs() {
return Math.max(...Object.values(this.oses), 1);
},
},
methods: {
deviceIcon(type) {
const map = { desktop: 'computer-desktop', mobile: 'device-phone-mobile', tablet: 'device-tablet' };
return map[type] || 'computer-desktop';
},
deviceLabel(type) {
const map = { desktop: 'Десктоп', mobile: 'Мобильные', tablet: 'Планшеты' };
return map[type] || type;
},
getDevicePercent(count) {
return Math.round((count / this.totalDevices) * 100);
},
getBrowserPercent(count) {
return Math.round((count / this.maxBrowser) * 100);
},
getOsPercent(count) {
return Math.round((count / this.maxOs) * 100);
},
},
};
</script>
@@ -0,0 +1,183 @@
<template>
<div class="space-y-0.5">
<div v-for="ms in filteredSections" :key="ms.id">
<!-- Home item -->
<a
v-if="ms.type === 'home'"
:href="sectionUrl('/')"
class="relative flex items-center justify-between py-2 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(ms.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative">
<DashboardIcon name="home" size="5" class="text-primary" />
<span class="text-sm font-medium text-foreground">{{ ms.title }}</span>
</div>
<div class="flex items-center gap-1.5 relative">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{{ ms.views }}
</span>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
{{ ms.visitors }}
</span>
</div>
</a>
<!-- Regular section -->
<template v-else>
<a
:href="sectionUrl('/' + ms.slug)"
class="relative flex items-center justify-between py-2 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(ms.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative min-w-0">
<button
v-if="ms.sub_sections && ms.sub_sections.length"
@click.stop.prevent="toggle(ms.id)"
class="p-0.5 rounded hover:bg-muted-hover transition-colors"
>
<svg
class="w-3 h-3 text-muted-foreground-2 transition-transform"
:class="{ 'rotate-90': expanded[ms.id] }"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<span v-else class="w-3"></span>
<span class="text-sm font-medium text-foreground truncate">{{ ms.title }}</span>
</div>
<div class="flex items-center gap-1.5 relative flex-shrink-0">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{{ ms.views }}
</span>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
{{ ms.visitors }}
</span>
</div>
</a>
<!-- Sub Sections (collapsible) -->
<div v-if="expanded[ms.id] && ms.sub_sections" class="ml-6 mt-1 space-y-0.5">
<div v-for="ss in ms.sub_sections" :key="ss.id">
<a
:href="sectionUrl('/' + ms.slug + '/' + ss.slug)"
class="relative flex items-center justify-between py-1.5 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(ss.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative min-w-0">
<button
v-if="ss.pages && ss.pages.length"
@click.stop.prevent="toggleMs(ms.id, ss.id)"
class="p-0.5 rounded hover:bg-muted-hover transition-colors"
>
<svg
class="w-2.5 h-2.5 text-muted-foreground-2 transition-transform"
:class="{ 'rotate-90': expandedMs[ms.id + '_' + ss.id] }"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<span v-else class="w-2.5"></span>
<span class="text-xs font-medium text-foreground truncate">{{ ss.title }}</span>
</div>
<div class="flex items-center gap-1.5 relative flex-shrink-0">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{{ ss.views }}
</span>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
{{ ss.visitors }}
</span>
</div>
</a>
<!-- Pages (collapsible) -->
<div v-if="expandedMs[ms.id + '_' + ss.id] && ss.pages" class="ml-5 mt-0.5 space-y-0.5">
<a
v-for="page in ss.pages"
:key="page.id"
:href="`/dashboard/analytics/section?prefix=${encodeURIComponent(page.url)}&days=${period}`"
class="relative flex items-center justify-between py-1 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(page.views) + '%' }"
></div>
<div class="flex items-center gap-1.5 relative min-w-0">
<DashboardIcon name="document" size="4" class="text-muted-foreground-2 flex-shrink-0" />
<span class="text-[11px] text-foreground truncate">{{ page.title }}</span>
</div>
<div class="flex items-center gap-1.5 relative flex-shrink-0">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{{ page.views }}
</span>
</div>
</a>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'AnalyticsNavigationSections',
components: { DashboardIcon },
props: {
navigation: { type: Object, required: true },
period: { type: Number, default: 30 },
group: { type: String, default: null },
},
data() {
return {
expanded: {},
expandedMs: {},
};
},
computed: {
filteredSections() {
if (!this.group) return this.navigation.sections;
return this.navigation.sections.filter(s => s.group === this.group);
},
maxViews() {
const views = this.filteredSections.map(s => s.views);
return Math.max(...views, 1);
},
},
methods: {
getWidth(views) {
return Math.max((views / this.maxViews) * 100, 2);
},
toggle(id) {
this.expanded[id] = !this.expanded[id];
},
toggleMs(msId, ssId) {
const key = msId + '_' + ssId;
this.expandedMs[key] = !this.expandedMs[key];
},
sectionUrl(prefix) {
return route('dashboard.analytics.section', { prefix, days: this.period });
},
},
};
</script>
@@ -0,0 +1,113 @@
<template>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div
v-for="card in statCards"
:key="card.label"
class="bg-layer border border-layer-line rounded-lg p-5 shadow-xs"
>
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :class="card.bgClass">
<DashboardIcon :name="card.icon" size="5" :class="card.iconClass" />
</div>
<span
v-if="card.change.direction !== 'neutral'"
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
:class="badgeClass(card)"
>
{{ card.change.direction === 'up' ? '↑' : '↓' }}
{{ formatChange(card) }}
</span>
</div>
<div class="space-y-1">
<p class="text-2xl font-semibold text-foreground">{{ formatValue(card.value, card.format) }}</p>
<p class="text-xs text-muted-foreground-1">{{ card.label }}</p>
</div>
</div>
</div>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'AnalyticsOverview',
components: { DashboardIcon },
props: {
overview: { type: Object, required: true },
},
computed: {
statCards() {
return [
{
icon: 'users',
bgClass: 'bg-blue-500/10',
iconClass: 'text-blue-600',
label: 'Уникальные посетители',
value: this.overview.unique_visitors.value,
change: this.overview.unique_visitors,
changeSuffix: '%',
format: 'number',
},
{
icon: 'eye',
bgClass: 'bg-emerald-500/10',
iconClass: 'text-emerald-600',
label: 'Просмотры страниц',
value: this.overview.page_views.value,
change: this.overview.page_views,
changeSuffix: '%',
format: 'number',
},
{
icon: 'clock',
bgClass: 'bg-violet-500/10',
iconClass: 'text-violet-600',
label: 'Среднее время',
value: this.overview.avg_time.value,
change: this.overview.avg_time,
changeSuffix: 'с',
format: 'duration',
},
{
icon: 'arrow-uturn-left',
bgClass: 'bg-rose-500/10',
iconClass: 'text-rose-600',
label: 'Отказы (Bounce Rate)',
value: this.overview.bounce_rate.value,
change: this.overview.bounce_rate,
changeSuffix: '%',
format: 'percent',
invertColor: true,
},
];
},
},
methods: {
formatValue(value, format) {
if (format === 'duration') {
const min = Math.floor(value / 60);
const sec = value % 60;
return min > 0 ? `${min}м ${sec}с` : `${sec}с`;
}
if (format === 'percent') return `${value}%`;
return new Intl.NumberFormat('ru-RU').format(value);
},
formatChange(card) {
const val = Math.abs(card.change.change);
if (card.format === 'duration') {
const min = Math.floor(val / 60);
const sec = val % 60;
return min > 0 ? `${min}м ${sec}с` : `${val}с`;
}
return `${val}${card.changeSuffix}`;
},
badgeClass(card) {
const isUp = card.change.direction === 'up';
const bad = card.invertColor ? isUp : !isUp;
return bad
? 'bg-red-500/10 text-red-600'
: 'bg-emerald-500/10 text-emerald-600';
},
},
};
</script>
@@ -0,0 +1,45 @@
<template>
<div class="flex items-center gap-2">
<div class="flex bg-layer border border-layer-line rounded-lg p-0.5">
<button
v-for="preset in presets"
:key="preset.value"
@click="selectPeriod(preset.value)"
class="px-3 py-1 text-xs font-medium rounded-md transition-colors"
:class="modelValue === preset.value
? 'bg-primary text-white'
: 'text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover'"
>
{{ preset.label }}
</button>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsPeriodFilter',
props: {
modelValue: {
type: Number,
default: 30,
},
},
emits: ['update:modelValue'],
data() {
return {
presets: [
{ label: 'Сегодня', value: 0 },
{ label: '7 дней', value: 7 },
{ label: '30 дней', value: 30 },
{ label: 'Все', value: 365 },
],
};
},
methods: {
selectPeriod(value) {
this.$emit('update:modelValue', value);
},
},
};
</script>
@@ -0,0 +1,36 @@
<template>
<div class="space-y-2">
<div v-if="items.length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-for="item in items" :key="item.source" class="group">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-foreground truncate">{{ item.source }}</span>
<span class="text-xs text-muted-foreground-1 ml-2 flex-shrink-0">{{ item.hits }}</span>
</div>
<div class="h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div
class="h-full bg-primary/60 rounded-full transition-all"
:style="{ width: getWidth(item.hits) + '%' }"
></div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsReferrers',
props: {
items: { type: Array, required: true },
},
computed: {
maxHits() {
return Math.max(...this.items.map(i => i.hits), 1);
},
},
methods: {
getWidth(hits) {
return Math.max((hits / this.maxHits) * 100, 2);
},
},
};
</script>
@@ -0,0 +1,51 @@
<template>
<div>
<div v-if="items.length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-1">
<a
v-for="item in items"
:key="item.prefix"
:href="sectionUrl(item.prefix)"
class="relative flex items-center justify-between py-2 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(item.views) + '%' }"
></div>
<div class="flex items-center gap-2 min-w-0 flex-1 relative">
<span class="text-xs font-medium text-foreground">{{ item.label }}</span>
<svg class="w-3 h-3 text-muted-foreground-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
<div class="flex items-center gap-3 flex-shrink-0 relative">
<span class="text-[11px] text-muted-foreground-1">{{ item.views }}</span>
<span class="text-[10px] text-muted-foreground-2">{{ item.visitors }} uniq</span>
</div>
</a>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsSections',
props: {
items: { type: Array, required: true },
period: { type: Number, default: 30 },
},
computed: {
maxViews() {
return Math.max(...this.items.map(i => i.views), 1);
},
},
methods: {
getWidth(views) {
return Math.max((views / this.maxViews) * 100, 2);
},
sectionUrl(prefix) {
return route('dashboard.analytics.section', { prefix, days: this.period });
},
},
};
</script>
@@ -0,0 +1,50 @@
<template>
<div class="space-y-1.5">
<div v-if="pages.length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div
v-for="page in pages"
:key="page.url"
class="relative group"
>
<div class="absolute inset-0 bg-primary/5 rounded-md" :style="{ width: getWidth(page.views) + '%' }"></div>
<div class="relative flex items-center justify-between py-1.5 px-2 rounded-md">
<div class="flex items-center gap-2 min-w-0 flex-1">
<a
:href="page.url"
target="_blank"
class="text-[11px] font-medium text-foreground truncate hover:text-primary transition-colors"
:title="page.url"
>
{{ page.url }}
</a>
<svg class="w-3 h-3 text-muted-foreground-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
<div class="flex items-center gap-3 flex-shrink-0 ml-2">
<span class="text-[11px] text-muted-foreground-1">{{ page.views }}</span>
<span class="text-[10px] text-muted-foreground-2">{{ page.unique_visitors }} uniq</span>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsTopPages',
props: {
pages: { type: Array, required: true },
},
computed: {
maxViews() {
return Math.max(...this.pages.map(p => p.views), 1);
},
},
methods: {
getWidth(views) {
return Math.max((views / this.maxViews) * 100, 2);
},
},
};
</script>
@@ -0,0 +1,73 @@
<template>
<transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="visible"
class="fixed bottom-0 inset-x-0 z-50 p-4 sm:p-6"
>
<div class="max-w-3xl mx-auto bg-layer border border-layer-line rounded-lg shadow-lg p-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex-1">
<h3 class="text-sm font-semibold text-foreground mb-1">Cookie и аналитика</h3>
<p class="text-xs text-muted-foreground-1 leading-relaxed">
Мы собираем анонимную статистику посещений для улучшения качества сайта.
Данные не передаются третьим лицам.
<a :href="route('page.view', { path: 'privacy-policy' })" class="underline hover:text-primary">
Подробнее
</a>
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button
@click="decline"
class="px-3 py-1.5 text-xs font-medium text-muted-foreground-1 hover:text-foreground border border-layer-line rounded-md transition-colors"
>
Отклонить
</button>
<button
@click="accept"
class="px-3 py-1.5 text-xs font-medium text-white bg-primary hover:bg-primary/90 rounded-md transition-colors"
>
Принять
</button>
</div>
</div>
</div>
</div>
</transition>
</template>
<script>
import { getConsent } from '@/services/analytics.js';
export default {
name: 'ConsentBanner',
data() {
return {
visible: false,
};
},
mounted() {
const consent = getConsent();
if (!consent) {
this.visible = true;
}
},
methods: {
accept() {
localStorage.setItem('analytics_consent', 'granted');
this.visible = false;
},
decline() {
localStorage.setItem('analytics_consent', 'denied');
this.visible = false;
},
},
};
</script>