Files
ntspi-app/resources/js/Pages/Dashboard/AnalyticsSectionDetail.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

185 lines
8.7 KiB
Vue

<template>
<DashboardLayout>
<template #header-title>{{ section.label }}</template>
<template #header-subtitle>Статистика раздела</template>
<template #header-actions>
<div class="flex items-center gap-2">
<AnalyticsPeriodFilter :model-value="period" @update:model-value="changePeriod" />
<a
:href="route('dashboard.analytics.index', { days: period })"
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"
>
Назад
</a>
</div>
</template>
<FlashMessages />
<!-- Breadcrumbs -->
<nav v-if="section.breadcrumbs && section.breadcrumbs.length" class="flex items-center gap-1.5 mb-4 text-xs text-muted-foreground-1">
<a :href="section.breadcrumbs[0].url" class="hover:text-primary transition-colors">{{ section.breadcrumbs[0].title }}</a>
<span v-for="(crumb, i) in section.breadcrumbs.slice(1)" :key="i" class="flex items-center gap-1.5">
<span>|</span>
<a :href="crumb.url" class="hover:text-primary transition-colors">{{ crumb.title }}</a>
</span>
<span class="flex items-center gap-1.5">
<span>/</span>
<span class="text-foreground font-medium">{{ section.label }}</span>
</span>
</nav>
<!-- Children (main_section sub_sections, sub_section pages) -->
<div v-if="section.children && section.children.length" 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">
{{ section.type === 'main_section' ? 'Подразделы' : 'Страницы' }}
</h3>
<div class="space-y-1">
<a
v-for="child in section.children"
:key="child.id"
:href="sectionUrl(child.prefix || child.url)"
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: getChildWidth(child.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative min-w-0">
<span class="text-xs font-medium text-foreground truncate">{{ child.title }}</span>
<span v-if="child.pages_count" class="text-[10px] text-muted-foreground-2">({{ child.pages_count }} стр.)</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>
{{ child.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>
{{ child.visitors }}
</span>
<svg class="w-3 h-3 text-muted-foreground-2 opacity-0 group-hover:opacity-100 transition-opacity" 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>
</a>
</div>
</div>
<!-- Summary -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-layer border border-layer-line rounded-lg p-5 shadow-xs">
<p class="text-2xl font-semibold text-foreground">{{ formatNumber(section.total_views) }}</p>
<p class="text-xs text-muted-foreground-1">Просмотров</p>
</div>
<div class="bg-layer border border-layer-line rounded-lg p-5 shadow-xs">
<p class="text-2xl font-semibold text-foreground">{{ formatNumber(section.unique_visitors) }}</p>
<p class="text-xs text-muted-foreground-1">Уникальных посетителей</p>
</div>
</div>
<!-- Chart -->
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5 mb-6">
<AnalyticsChart :data="section.daily_stats" />
</div>
<!-- Widgets -->
<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>
<AnalyticsReferrers :items="section.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>
<div class="space-y-2">
<div v-for="(count, type) in section.devices" :key="type" class="flex items-center justify-between">
<span class="text-xs font-medium text-foreground">{{ deviceLabel(type) }}</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: getDevicePercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-8 text-right">{{ count }}</span>
</div>
</div>
</div>
</div>
<!-- Exit pages (only for page type) -->
<div v-if="section.type === 'page' && section.exit_pages && section.exit_pages.length" class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Куда ушли</h3>
<div class="space-y-1.5">
<div v-for="ep in section.exit_pages" :key="ep.url" class="flex items-center justify-between">
<span class="text-[11px] font-medium text-foreground truncate">{{ ep.url }}</span>
<span class="text-[11px] text-muted-foreground-1">{{ ep.count }}</span>
</div>
</div>
</div>
<!-- Top pages (not for page type) -->
<div v-if="section.type !== 'page' && section.top_pages && section.top_pages.length" 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="section.top_pages" />
</div>
</div>
</DashboardLayout>
</template>
<script>
import DashboardLayout from './Components/DashboardLayout.vue';
import FlashMessages from './Components/shared/FlashMessages.vue';
import AnalyticsChart from './Components/shared/AnalyticsChart.vue';
import AnalyticsTopPages from './Components/shared/AnalyticsTopPages.vue';
import AnalyticsReferrers from './Components/shared/AnalyticsReferrers.vue';
import AnalyticsPeriodFilter from './Components/shared/AnalyticsPeriodFilter.vue';
export default {
name: 'AnalyticsSectionDetail',
components: {
DashboardLayout,
FlashMessages,
AnalyticsChart,
AnalyticsTopPages,
AnalyticsReferrers,
AnalyticsPeriodFilter,
},
props: {
section: { type: Object, required: true },
period: { type: Number, default: 30 },
},
computed: {
totalDevices() {
return Object.values(this.section.devices || {}).reduce((a, b) => a + b, 0) || 1;
},
maxChildViews() {
if (!this.section.children || !this.section.children.length) return 1;
return Math.max(...this.section.children.map(c => c.views), 1);
},
},
mounted() {
this.SET_DOCUMENT_TITLE(this.section.label + ' — Аналитика');
},
methods: {
formatNumber(n) {
return new Intl.NumberFormat('ru-RU').format(n);
},
deviceLabel(type) {
const map = { desktop: 'Десктоп', mobile: 'Мобильные', tablet: 'Планшеты' };
return map[type] || type;
},
getDevicePercent(count) {
return Math.round((count / this.totalDevices) * 100);
},
getChildWidth(views) {
return Math.max((views / this.maxChildViews) * 100, 2);
},
sectionUrl(prefix) {
return route('dashboard.analytics.section', { prefix, days: this.period });
},
changePeriod(days) {
this.$inertia.get(route('dashboard.analytics.section'), { prefix: this.section.prefix, days }, { preserveState: true });
},
},
};
</script>