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,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>