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
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Database\Seeders;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\User\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class AnalyticsMockSeeder extends Seeder
{
private const URLS = [
'/', '/news', '/faculties', '/faculties/1', '/faculties/2', '/faculties/3',
'/departments', '/departments/1', '/departments/2', '/departments/3',
'/schedules', '/schedules/1', '/schedules/2', '/events', '/events/1',
'/additional-educations', '/additional-educations/1',
'/page/abiturientam', '/page/studentam', '/page/nauchnaya-rabota',
'/page/ob-institute', '/page/struktura', '/page/obrazovanie',
'/news/1', '/news/2', '/news/3', '/news/4', '/news/5',
'/news/6', '/news/7', '/news/8', '/news/9', '/news/10',
'/departments/1/workers', '/departments/2/workers', '/departments/3/workers',
'/faculties/1/workers', '/faculties/2/workers',
];
private const BROWSERS = ['Chrome', 'Chrome', 'Chrome', 'YandexBrowser', 'Firefox', 'Safari', 'Edge'];
private const OSSES = ['Windows', 'Windows', 'Windows', 'macOS', 'Linux', 'Android', 'iOS'];
private const DEVICES = ['desktop', 'desktop', 'desktop', 'desktop', 'mobile', 'mobile', 'tablet'];
private const COUNTRIES = ['RU', 'RU', 'RU', 'RU', 'BY', 'KZ', 'UZ'];
private const CITIES = [
'Moscow', 'Saint Petersburg', 'Novosibirsk', 'Yekaterinburg', 'Kazan',
'Nizhny Tagil', 'Chelyabinsk', 'Minsk', 'Almaty', 'Tashkent',
];
private const SOURCES = [
null, null, null, null, null,
'https://www.google.com/search?q=ntspi',
'https://yandex.ru/search/?text=ntspi',
'https://vk.com/ntspi_official',
'https://t.me/ntspi_news',
'https://dzen.ru/a/ntspi',
];
public function run(): void
{
$this->command?->info('Creating analytics mock data (30 days)...');
for ($day = 0; $day < 30; $day++) {
$date = now()->subDays($day);
$sessionsCount = rand(8, 40);
for ($s = 0; $s < $sessionsCount; $s++) {
$visitorId = Str::uuid();
$hitsCount = rand(1, 12);
$browser = self::BROWSERS[array_rand(self::BROWSERS)];
$os = self::OSSES[array_rand(self::OSSES)];
$device = self::DEVICES[array_rand(self::DEVICES)];
$startedAt = $date->copy()->addHours(rand(0, 23))->addMinutes(rand(0, 59));
$lastActivity = $startedAt->copy()->addSeconds(rand(10, 1800));
$session = AnalyticsSession::create([
'visitor_id' => $visitorId,
'user_id' => rand(1, 10) > 8 ? User::inRandomOrder()->value('id') : null,
'entry_page' => self::URLS[array_rand(self::URLS)],
'ip' => long2ip(rand(0, 0xFFFFFF00)),
'country' => self::COUNTRIES[array_rand(self::COUNTRIES)],
'city' => self::CITIES[array_rand(self::CITIES)],
'utm_source' => rand(1, 10) > 7 ? ['google', 'vk', 'telegram', 'dzen'][array_rand(['google', 'vk', 'telegram', 'dzen'])] : null,
'utm_medium' => null,
'utm_campaign' => null,
'browser' => $browser,
'os' => $os,
'device_type' => $device,
'started_at' => $startedAt,
'last_activity_at' => $lastActivity,
]);
for ($h = 0; $h < $hitsCount; $h++) {
$hitTime = $startedAt->copy()->addSeconds(rand(0, max(1, $lastActivity->timestamp - $startedAt->timestamp)));
AnalyticsHit::create([
'session_id' => $session->id,
'user_id' => $session->user_id,
'url' => self::URLS[array_rand(self::URLS)],
'referrer' => self::SOURCES[array_rand(self::SOURCES)],
'title' => null,
'created_at' => $hitTime,
]);
}
}
}
$this->command?->info('Analytics mock: ' . AnalyticsSession::count() . ' sessions, ' . AnalyticsHit::count() . ' hits');
}
}
+1
View File
@@ -9,6 +9,7 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$this->call([
RolesSeeder::class,
MockDataSeeder::class,
]);
}
+44 -33
View File
@@ -16,8 +16,6 @@ use App\Containers\Education\Models\AdmissionCampaign;
use App\Containers\Education\Models\AdmissionPlan;
use App\Containers\Education\Models\DirectionStudy;
use App\Containers\Education\Models\EducationalProgram;
use App\Containers\Event\Models\Event;
use App\Containers\Event\Models\EventCategory;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\InstituteStructure\Models\Division;
use App\Containers\InstituteStructure\Models\Faculty;
@@ -49,52 +47,61 @@ class MockDataSeeder extends Seeder
// 1. Users & Roles
$this->createUsers();
// 2. Institute Structure
$faculties = Faculty::factory()->count(8)->create();
$departments = Department::factory()->count(15)->create();
$divisions = Division::factory()->count(5)->create();
// 2. Institute Structure: 6 факультетов, по 2 кафедры на каждый
$faculties = Faculty::factory()->count(6)->create();
$departments = collect();
foreach ($faculties as $faculty) {
$departments = $departments->merge(
Department::factory()->count(2)->create(['faculty_id' => $faculty->id])
);
}
$divisions = Division::factory()->count(20)->create();
// 3. Pivot: workers_faculties, workers_departments, teachers_departments
$this->attachWorkers($faculties, $departments);
// 4. Article
$categories = Category::factory()->count(20)->create();
Post::factory()->count(100)->create();
Post::factory()->count(1000)->create();
Tag::factory()->count(30)->create();
// 5. Events
$eventCategories = EventCategory::factory()->count(8)->create();
Event::factory()->count(50)->create();
// 6. Education
// 5. Education
$directionStudies = DirectionStudy::factory()->count(10)->create();
$programs = EducationalProgram::factory()->count(25)->create();
$campaigns = AdmissionCampaign::factory()->count(5)->create();
AdmissionPlan::factory()->count(30)->create();
$programs = EducationalProgram::factory()->count(25)
->sequence(fn () => ['direction_study_id' => $directionStudies->random()->id])
->create();
$campaigns = AdmissionCampaign::factory()->count(6)->create();
AdmissionPlan::factory()->count(30)
->sequence(fn () => [
'admission_campaigns_id' => $campaigns->random()->id,
'educational_programs_id' => $programs->random()->id,
])
->create();
// 7. Pivot: program_department
// 6. Pivot: program_department
$this->attachProgramsToDepartments($programs, $departments);
// 8. Additional Education
$directions = DirectionAdditionalEducation::factory()->count(5)->create();
$addCategories = AdditionalEducationCategory::factory()->count(12)->create();
// 7. Additional Education
DirectionAdditionalEducation::factory()->count(5)->create();
AdditionalEducationCategory::factory()->count(12)->create();
AdditionalEducation::factory()->count(20)->create();
// 9. Science
$journals = AcademicJournal::factory()->count(4)->create();
// 8. Science
AcademicJournal::factory()->count(4)->create();
JournalIssue::factory()->count(15)->create();
// 10. Schedule
// 9. Schedule
$groups = EducationalGroup::factory()->count(40)->create();
Schedule::factory()->count(90)->create();
// 11. Widgets
// 10. Widgets: 1 главный слайдер + 5 слайдов
Slider::create([
'title' => 'Главный слайдер',
'slug' => 'quos-velit-quisquam',
'is_active' => true,
]);
Slider::factory()->count(4)->create();
$mainSlider = Slider::where('slug', 'quos-velit-quisquam')->first();
Slide::factory()->count(5)->create([
@@ -103,8 +110,8 @@ class MockDataSeeder extends Seeder
'start_time' => now()->subWeek(),
'end_time' => now()->addMonth(),
]);
Slide::factory()->count(15)->create();
// 11. Contact & Reference widgets
ContactWidget::create([
'title' => 'Главная страница контакты',
'slug' => 'glavnaia-stranica-kontakty',
@@ -130,7 +137,6 @@ class MockDataSeeder extends Seeder
],
'is_active' => true,
]);
ContactWidget::factory()->count(2)->create();
PageReferenceList::create([
'title' => 'Главная страница ресурсы',
@@ -142,8 +148,8 @@ class MockDataSeeder extends Seeder
],
'is_active' => true,
]);
PageReferenceList::factory()->count(4)->create();
$forms = CustomForm::factory()->count(8)->create();
CustomForm::factory()->count(8)->create();
CustomFormResponse::factory()->count(40)->create();
// 12. App Structure
@@ -152,10 +158,16 @@ class MockDataSeeder extends Seeder
->count(15)
->sequence(fn () => ['main_section_id' => $mainSections->random()->id])
->create();
Page::factory()
->count(40)
->sequence(fn () => ['sub_section_id' => $subSections->random()->id])
->create();
$pages = collect();
for ($i = 0; $i < 40; $i++) {
$ss = $subSections->random();
$ms = $ss->mainSection;
$page = Page::factory()->make(['sub_section_id' => $ss->id]);
$page->path = $ms->slug . '/' . $ss->slug . '/' . $page->slug;
$page->save();
$pages->push($page);
}
// 13. Integration Credentials
IntegrationCredential::factory()->count(3)->create();
@@ -188,7 +200,6 @@ class MockDataSeeder extends Seeder
);
User::factory()->count(50)->create();
UserDetail::factory()->count(30)->create();
}