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:
@@ -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>
|
||||
Reference in New Issue
Block a user