Files
ntspi-app/resources/js/Pages/Dashboard/Analytics.vue
T
F4ilji d6c0f48d48 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
2026-09-18 22:18:04 +05:00

133 lines
5.6 KiB
Vue

<template>
<DashboardLayout>
<template #header-title>Аналитика сайта</template>
<template #header-subtitle>Статистика посещений и активности пользователей</template>
<template #header-actions>
<div class="flex items-center gap-2">
<AnalyticsPeriodFilter :model-value="period" @update:model-value="changePeriod" />
<div class="relative" ref="settingsWrap">
<button
@click="showSettings = !showSettings"
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<div
v-if="showSettings"
class="absolute right-0 top-full mt-1 bg-layer border border-layer-line rounded-lg shadow-lg p-2 z-10 min-w-[160px]"
>
<button
@click="clearData"
class="w-full text-left px-3 py-2 text-xs text-red-600 hover:bg-red-50 rounded-md transition-colors"
>
Очистить данные
</button>
</div>
</div>
</div>
</template>
<FlashMessages />
<AnalyticsOverview :overview="overview" class="mb-6" />
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5 mb-6">
<AnalyticsChart :data="dailyStats" />
</div>
<!-- Groups -->
<div v-for="groupKey in groupKeys" :key="groupKey" class="bg-layer border border-layer-line rounded-lg shadow-xs p-5 mb-6">
<h3 class="text-sm font-semibold text-foreground mb-3">{{ navigation.groupLabels[groupKey] || groupKey }}</h3>
<AnalyticsNavigationSections :navigation="navigation" :period="period" :group="groupKey" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Топ страниц</h3>
<AnalyticsTopPages :pages="topPages" />
</div>
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Источники трафика</h3>
<AnalyticsReferrers :items="referrers" />
</div>
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Устройства</h3>
<AnalyticsDevices :devices="devices.devices" :browsers="devices.browsers" :oses="devices.oses" />
</div>
</div>
</DashboardLayout>
</template>
<script>
import DashboardLayout from './Components/DashboardLayout.vue';
import FlashMessages from './Components/shared/FlashMessages.vue';
import AnalyticsOverview from './Components/shared/AnalyticsOverview.vue';
import AnalyticsChart from './Components/shared/AnalyticsChart.vue';
import AnalyticsTopPages from './Components/shared/AnalyticsTopPages.vue';
import AnalyticsReferrers from './Components/shared/AnalyticsReferrers.vue';
import AnalyticsDevices from './Components/shared/AnalyticsDevices.vue';
import AnalyticsPeriodFilter from './Components/shared/AnalyticsPeriodFilter.vue';
import AnalyticsNavigationSections from './Components/shared/AnalyticsNavigationSections.vue';
export default {
name: 'Analytics',
components: {
DashboardLayout,
FlashMessages,
AnalyticsOverview,
AnalyticsChart,
AnalyticsTopPages,
AnalyticsReferrers,
AnalyticsDevices,
AnalyticsPeriodFilter,
AnalyticsNavigationSections,
},
props: {
overview: { type: Object, required: true },
topPages: { type: Array, required: true },
dailyStats: { type: Array, required: true },
referrers: { type: Array, required: true },
devices: { type: Object, required: true },
navigation: { type: Object, required: true },
period: { type: Number, default: 30 },
},
data() {
return { showSettings: false };
},
computed: {
groupKeys() {
const keys = new Set(this.navigation.sections.map(s => s.group).filter(Boolean));
return [...keys];
},
},
mounted() {
this.SET_DOCUMENT_TITLE('Аналитика');
document.addEventListener('click', this.handleClickOutside);
},
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutside);
},
methods: {
changePeriod(days) {
this.$inertia.get(route('dashboard.analytics.index'), { days }, { preserveState: true });
},
clearData() {
this.showSettings = false;
if (confirm('Удалить все данные аналитики? Это действие необратимо.')) {
this.$inertia.post(route('dashboard.analytics.clear'));
}
},
handleClickOutside(e) {
if (this.$refs.settingsWrap && !this.$refs.settingsWrap.contains(e.target)) {
this.showSettings = false;
}
},
},
};
</script>