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,48 @@
<?php
namespace App\Containers\Analytics\Commands;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\DB;
class AggregateAnalyticsCommand extends \Illuminate\Console\Command
{
protected $signature = 'analytics:aggregate {--days=1 : Days back to aggregate}';
protected $description = 'Aggregate analytics hits into daily stats';
public function handle(): int
{
$days = (int) $this->option('days');
$date = now()->subDays($days)->toDateString();
$stats = AnalyticsHit::selectRaw('
DATE(created_at) as hit_date,
url,
COUNT(*) as views_count,
COUNT(DISTINCT session_id) as unique_visitors
')
->whereDate('created_at', $date)
->groupBy(DB::raw('DATE(created_at)'), 'url')
->get();
$inserted = 0;
foreach ($stats as $row) {
AnalyticsDailyStat::updateOrCreate(
['date' => $row->hit_date, 'url' => $row->url],
[
'views_count' => $row->views_count,
'unique_visitors' => $row->unique_visitors,
]
);
$inserted++;
}
$this->info("Aggregated {$inserted} daily stat rows for {$date}");
return static::SUCCESS;
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Containers\Analytics\Jobs;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\Analytics\Services\DeviceDetectorService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecordVisitJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 15;
public function __construct(
public array $data,
) {}
public function handle(
DeviceDetectorService $detector,
): void {
$userAgent = $this->data['user_agent'] ?? '';
if ($detector->isBot($userAgent)) {
return;
}
$device = $detector->parse($userAgent);
$utmParams = array_filter([
'utm_source' => $this->data['utm_source'] ?? null,
'utm_medium' => $this->data['utm_medium'] ?? null,
'utm_campaign' => $this->data['utm_campaign'] ?? null,
], fn($v) => $v !== null);
$session = (new AnalyticsSession())->firstOrCreateSession(
visitorId: $this->data['visitor_id'],
ip: $this->data['ip'] ?? '0.0.0.0',
userId: $this->data['user_id'] ?? null,
entryPage: $this->data['url'],
utmParams: $utmParams,
);
if (empty($session->browser)) {
$session->update([
'browser' => $device['browser'],
'os' => $device['os'],
'device_type' => $device['type'],
]);
}
AnalyticsHit::create([
'session_id' => $session->id,
'user_id' => $this->data['user_id'] ?? null,
'url' => $this->data['url'],
'referrer' => $this->data['referrer'] ?? null,
'title' => $this->data['title'] ?? null,
'created_at' => now(),
]);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
class AnalyticsDailyStat extends Model
{
protected $table = 'analytics_daily_stats';
protected $fillable = [
'date',
'url',
'views_count',
'unique_visitors',
];
protected $casts = [
'date' => 'date',
'views_count' => 'integer',
'unique_visitors' => 'integer',
];
}
@@ -0,0 +1,36 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsHit extends Model
{
protected $table = 'analytics_hits';
public $timestamps = false;
protected $fillable = [
'session_id',
'user_id',
'url',
'referrer',
'title',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function session(): BelongsTo
{
return $this->belongsTo(AnalyticsSession::class, 'session_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id');
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsSession extends Model
{
protected $table = 'analytics_sessions';
protected $fillable = [
'visitor_id',
'user_id',
'entry_page',
'ip',
'utm_source',
'utm_medium',
'utm_campaign',
'browser',
'os',
'device_type',
'started_at',
'last_activity_at',
];
protected $casts = [
'started_at' => 'datetime',
'last_activity_at' => 'datetime',
];
public function hits(): HasMany
{
return $this->hasMany(AnalyticsHit::class, 'session_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id');
}
public function firstOrCreateSession(
string $visitorId,
string $ip,
int $userId = null,
string $entryPage = null,
array $utmParams = [],
): self {
$lastSession = static::where('visitor_id', $visitorId)
->where('last_activity_at', '>', now()->subMinutes(30))
->latest('last_activity_at')
->first();
if ($lastSession) {
$lastSession->update(['last_activity_at' => now()]);
return $lastSession;
}
return static::create([
'visitor_id' => $visitorId,
'user_id' => $userId,
'entry_page' => $entryPage,
'ip' => $ip,
'utm_source' => $utmParams['utm_source'] ?? null,
'utm_medium' => $utmParams['utm_medium'] ?? null,
'utm_campaign' => $utmParams['utm_campaign'] ?? null,
'started_at' => now(),
'last_activity_at' => now(),
]);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Containers\Analytics\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Console\Scheduling\Schedule;
class AnalyticsServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
\App\Containers\Analytics\Commands\AggregateAnalyticsCommand::class,
]);
$this->app->afterResolving(Schedule::class, function (Schedule $schedule) {
$schedule->command('analytics:aggregate', ['--days' => 1])->hourly();
});
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Containers\Analytics\Services;
use WhichBrowser\Parser;
class DeviceDetectorService
{
public function parse(string $userAgent): array
{
$parser = new Parser($userAgent);
$browser = $parser->browser->name ?? null;
$os = $parser->os->name ?? null;
$deviceType = 'desktop';
if ($parser->isType('tablet')) {
$deviceType = 'tablet';
} elseif ($parser->isType('mobile')) {
$deviceType = 'mobile';
}
return [
'browser' => $browser,
'os' => $os,
'type' => $deviceType,
];
}
public function isBot(string $userAgent): bool
{
$parser = new Parser($userAgent);
return $parser->isType('bot');
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Containers\Analytics\Services;
use App\Containers\Analytics\Models\AnalyticsGeoCache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeoIpService
{
private const CACHE_TTL_DAYS = 7;
public function locate(string $ip): ?array
{
$ipHash = sha1($ip);
$cached = AnalyticsGeoCache::where('ip_hash', $ipHash)
->where('cached_at', '>', now()->subDays(self::CACHE_TTL_DAYS))
->first();
if ($cached) {
return [
'country' => $cached->country,
'city' => $cached->city,
];
}
try {
$response = Http::timeout(3)->get("http://ip-api.com/json/{$ip}", [
'fields' => 'country,city',
]);
if ($response->successful()) {
$data = $response->json();
AnalyticsGeoCache::updateOrCreate(
['ip_hash' => $ipHash],
[
'country' => $data['country'] ?? null,
'city' => $data['city'] ?? null,
'cached_at' => now(),
]
);
return [
'country' => $data['country'] ?? null,
'city' => $data['city'] ?? null,
];
}
} catch (\Throwable $e) {
Log::warning('GeoIP lookup failed', ['ip' => $ip, 'error' => $e->getMessage()]);
}
return null;
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetAnalyticsOverviewTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$to = now()->endOfDay();
if ($days === 0) {
$prevFrom = Carbon::yesterday()->startOfDay();
$prevTo = Carbon::yesterday()->endOfDay();
} else {
$prevFrom = Carbon::now()->subDays($days * 2)->startOfDay();
$prevTo = Carbon::now()->subDays($days)->startOfDay();
}
$current = $this->getPeriodStats($from, $to);
$previous = $this->getPeriodStats($prevFrom, $prevTo);
return [
'unique_visitors' => $this->compare($current['visitors'], $previous['visitors']),
'page_views' => $this->compare($current['views'], $previous['views']),
'avg_time' => $this->compare($current['avg_time'], $previous['avg_time'], true),
'bounce_rate' => $this->compare($current['bounce_rate'], $previous['bounce_rate'], true, true),
];
}
private function getPeriodStats(Carbon $from, Carbon $to): array
{
$hits = AnalyticsHit::whereBetween('created_at', [$from, $to])->count();
$sessions = AnalyticsSession::whereBetween('started_at', [$from, $to])
->get(['id', 'started_at', 'last_activity_at']);
$uniqueVisitors = AnalyticsSession::whereBetween('started_at', [$from, $to])
->distinct('visitor_id')
->count('visitor_id');
$totalSessions = $sessions->count();
$avgTime = 0;
if ($totalSessions > 0) {
$totalSeconds = $sessions->sum(function ($s) {
return $s->started_at->diffInSeconds($s->last_activity_at);
});
$avgTime = (int) round($totalSeconds / $totalSessions);
}
$bounceRate = 0;
if ($totalSessions > 0) {
$sessionIds = $sessions->pluck('id');
$singleHitSessions = AnalyticsHit::whereIn('session_id', $sessionIds)
->selectRaw('session_id, COUNT(*) as hits')
->groupBy('session_id')
->havingRaw('COUNT(*) = 1')
->count();
$bounceRate = (int) round(($singleHitSessions / $totalSessions) * 100);
}
return [
'views' => $hits,
'visitors' => $uniqueVisitors,
'avg_time' => $avgTime,
'bounce_rate' => $bounceRate,
];
}
private function compare(int $current, int $previous, bool $isRatio = false, bool $invertDirection = false): array
{
$change = 0;
if ($previous > 0) {
$change = $isRatio
? $current - $previous
: (int) round((($current - $previous) / $previous) * 100);
} elseif ($current > 0) {
$change = $isRatio ? 0 : 100;
}
$direction = $invertDirection
? ($change < 0 ? 'up' : ($change > 0 ? 'down' : 'neutral'))
: ($change > 0 ? 'up' : ($change < 0 ? 'down' : 'neutral'));
return [
'value' => $current,
'change' => $change,
'direction' => $direction,
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetDailyStatsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$stats = AnalyticsDailyStat::where('date', '>=', $from)
->selectRaw('
date,
SUM(views_count) as total_views,
SUM(unique_visitors) as total_unique
')
->groupBy('date')
->orderBy('date')
->get();
if ($stats->isEmpty()) {
return AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('
DATE(created_at) as hit_date,
COUNT(*) as total_views,
COUNT(DISTINCT session_id) as total_unique
')
->groupBy(DB::raw('DATE(created_at)'))
->orderBy('hit_date')
->get()
->map(fn($row) => [
'date' => (string) $row->hit_date,
'views' => (int) $row->total_views,
'visitors' => (int) $row->total_unique,
])
->toArray();
}
return $stats->map(fn($row) => [
'date' => $row->date instanceof Carbon ? $row->date->format('Y-m-d') : (string) $row->date,
'views' => (int) $row->total_views,
'visitors' => (int) $row->total_unique,
])->toArray();
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsSession;
use Illuminate\Support\Carbon;
class GetDevicesTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$devices = AnalyticsSession::where('started_at', '>=', $from)
->selectRaw('
device_type,
COUNT(*) as count
')
->groupBy('device_type')
->get()
->pluck('count', 'device_type')
->toArray();
$browsers = AnalyticsSession::where('started_at', '>=', $from)
->whereNotNull('browser')
->selectRaw('
browser,
COUNT(*) as count
')
->groupBy('browser')
->orderByDesc('count')
->limit(10)
->get()
->pluck('count', 'browser')
->toArray();
$oses = AnalyticsSession::where('started_at', '>=', $from)
->whereNotNull('os')
->selectRaw('
os,
COUNT(*) as count
')
->groupBy('os')
->orderByDesc('count')
->limit(10)
->get()
->pluck('count', 'os')
->toArray();
return [
'devices' => $devices,
'browsers' => $browsers,
'oses' => $oses,
];
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\AppStructure\Models\MainSection;
use Illuminate\Support\Carbon;
class GetNavigationSectionsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$hits = AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('url, session_id')
->get();
$mainSections = MainSection::with('subSections.pages')
->orderBy('sort')
->get();
$sections = [];
$cmsSlugs = [];
// 1. CMS sections (MainSection → SubSection → Page)
foreach ($mainSections as $ms) {
$msPrefix = '/' . $ms->slug;
$cmsSlugs[] = $msPrefix;
$msHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $msPrefix));
$subSections = [];
foreach ($ms->subSections as $ss) {
$ssPrefix = $msPrefix . '/' . $ss->slug;
$ssHits = $msHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $ssPrefix));
$pages = [];
foreach ($ss->pages as $page) {
$pagePath = '/' . $page->path;
$pHits = $ssHits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pagePath);
$pages[] = [
'id' => $page->id,
'title' => $page->title ?: $page->slug,
'path' => $page->path,
'url' => $pagePath,
'views' => $pHits->count(),
'visitors' => $pHits->pluck('session_id')->unique()->count(),
];
}
$subSections[] = [
'id' => $ss->id,
'title' => $ss->title,
'slug' => $ss->slug,
'views' => $ssHits->count(),
'visitors' => $ssHits->pluck('session_id')->unique()->count(),
'pages' => $pages,
];
}
$sections[] = [
'id' => $ms->id,
'title' => $ms->title,
'slug' => $ms->slug,
'group' => 'structure',
'views' => $msHits->count(),
'visitors' => $msHits->pluck('session_id')->unique()->count(),
'sub_sections' => $subSections,
];
}
// 2. Config-defined sections (non-CMS)
$configSections = config('analytics.sections', []);
foreach ($configSections as $prefix => $config) {
if ($prefix === '/') continue;
if (in_array($prefix, $cmsSlugs)) continue;
$sectionHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $prefix));
$subSections = [];
if (isset($config['children'])) {
$model = $config['children']['model'];
$nameKey = $config['children']['name_key'];
$slugKey = $config['children']['slug_key'];
$children = $model::all();
foreach ($children as $child) {
$childSlug = $child->{$slugKey};
$childPrefix = $prefix . '/' . $childSlug;
$childHits = $sectionHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $childPrefix));
$subSections[] = [
'id' => $child->id,
'title' => $child->{$nameKey},
'slug' => $childSlug,
'views' => $childHits->count(),
'visitors' => $childHits->pluck('session_id')->unique()->count(),
'pages' => [],
];
}
}
$sections[] = [
'id' => 'config:' . $prefix,
'title' => $config['label'],
'slug' => ltrim($prefix, '/'),
'group' => $config['group'] ?? 'other',
'views' => $sectionHits->count(),
'visitors' => $sectionHits->pluck('session_id')->unique()->count(),
'sub_sections' => $subSections,
];
}
// Homepage — as first item in structure group
$homeHits = $hits->filter(fn($h) => (rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === '/'));
array_unshift($sections, [
'id' => 'home',
'title' => 'Главная страница',
'slug' => '',
'group' => 'structure',
'type' => 'home',
'views' => $homeHits->count(),
'visitors' => $homeHits->pluck('session_id')->unique()->count(),
'sub_sections' => [],
]);
return [
'sections' => $sections,
'groupLabels' => config('analytics.group_labels', []),
];
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetReferrersTask
{
public function run(int $days = 30, int $limit = 10): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$referrers = AnalyticsHit::where('created_at', '>=', $from)
->whereNotNull('referrer')
->where('referrer', '!=', '')
->selectRaw('
CASE
WHEN referrer LIKE "%google.%" THEN "Google"
WHEN referrer LIKE "%yandex.%" THEN "Yandex"
WHEN referrer LIKE "%vk.com%" THEN "VK"
WHEN referrer LIKE "%t.me%" THEN "Telegram"
WHEN referrer LIKE "%ok.ru%" THEN "Одноклассники"
WHEN referrer LIKE "%dzen.ru%" THEN "Дзен"
WHEN referrer LIKE "%facebook.%" THEN "Facebook"
WHEN referrer LIKE "%instagram.%" THEN "Instagram"
WHEN referrer LIKE "%twitter.%" THEN "Twitter"
ELSE SUBSTRING_INDEX(SUBSTRING_INDEX(referrer, "/", 3), "/", -1)
END as source,
COUNT(*) as hits,
COUNT(DISTINCT session_id) as visitors
')
->groupBy('source')
->orderByDesc('hits')
->limit($limit)
->get()
->toArray();
$directCount = AnalyticsHit::where('created_at', '>=', $from)
->where(function ($q) {
$q->whereNull('referrer')->orWhere('referrer', '');
})
->count();
$results = array_map(fn($r) => [
'source' => $r['source'],
'hits' => (int) $r['hits'],
'visitors' => (int) $r['visitors'],
], $referrers);
if ($directCount > 0) {
array_unshift($results, [
'source' => 'Прямой заход',
'hits' => $directCount,
'visitors' => $directCount,
]);
}
return $results;
}
}
@@ -0,0 +1,216 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\AppStructure\Models\MainSection;
use App\Containers\AppStructure\Models\Page;
use App\Containers\AppStructure\Models\SubSection;
use Illuminate\Support\Carbon;
class GetSectionDetailTask
{
public function run(string $prefix, int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$hits = AnalyticsHit::where('created_at', '>=', $from)
->where('url', 'like', $prefix . '%')
->selectRaw('url, session_id, created_at, referrer')
->get();
$totalViews = $hits->count();
$uniqueVisitors = $hits->pluck('session_id')->unique()->count();
$mainSection = null;
$subSection = null;
$page = null;
$children = [];
$breadcrumbs = [];
$type = 'unknown';
$exitPages = [];
$cleanPrefix = ltrim($prefix, '/');
$parts = array_filter(explode('/', $cleanPrefix));
// 1. Check if MainSection
$mainSection = MainSection::where('slug', $cleanPrefix)->first();
if ($mainSection) {
$type = 'main_section';
$breadcrumbs = [['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')]];
$subSections = SubSection::where('main_section_id', $mainSection->id)
->with('pages')
->orderBy('sort')
->get();
foreach ($subSections as $ss) {
$ssPrefix = '/' . $mainSection->slug . '/' . $ss->slug;
$ssHits = $hits->filter(fn($h) => str_starts_with(parse_url($h->url, PHP_URL_PATH) ?: '', $ssPrefix));
$children[] = [
'id' => $ss->id,
'title' => $ss->title,
'prefix' => $ssPrefix,
'views' => $ssHits->count(),
'visitors' => $ssHits->pluck('session_id')->unique()->count(),
'pages_count' => $ss->pages->count(),
];
}
}
// 2. Check if SubSection (2 parts)
elseif (count($parts) === 2) {
$mainSection = MainSection::where('slug', $parts[0])->first();
$subSection = SubSection::where('slug', $parts[1])
->where('main_section_id', $mainSection?->id)
->first();
if ($subSection && $mainSection) {
$type = 'sub_section';
$breadcrumbs = [
['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')],
['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])],
];
$pages = Page::where('sub_section_id', $subSection->id)
->orderBy('sort')
->get();
foreach ($pages as $p) {
$pUrl = '/' . $p->path;
$pHits = $hits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pUrl);
$children[] = [
'id' => $p->id,
'title' => $p->title ?: $p->slug,
'url' => $pUrl,
'views' => $pHits->count(),
'visitors' => $pHits->pluck('session_id')->unique()->count(),
];
}
}
}
// 3. Check if Page (by path or 3+ parts)
if ($type === 'unknown') {
$page = Page::where('path', $cleanPrefix)->first();
if ($page && $page->section) {
$subSection = $page->section;
$mainSection = $subSection->mainSection;
}
if ($mainSection && $subSection) {
$type = 'page';
$breadcrumbs = [
['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')],
['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])],
['title' => $subSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug . '/' . $subSection->slug])],
];
// Exit pages: URLs visited after this page in the same session
$exitPages = $this->getExitPages($hits, $prefix, $days);
}
}
$label = $cleanPrefix;
if ($mainSection) $label = $mainSection->title;
if ($subSection) $label = $subSection->title;
if ($page) $label = $page->title ?: $page->slug;
$topPages = $hits->groupBy('url')
->map(fn($group) => [
'url' => $group->first()->url,
'views' => $group->count(),
'unique_visitors' => $group->pluck('session_id')->unique()->count(),
])
->sortByDesc('views')
->values()
->take(15)
->toArray();
$dailyStats = $hits->groupBy(fn($h) => $h->created_at->format('Y-m-d'))
->map(fn($group, $date) => [
'date' => $date,
'views' => $group->count(),
'visitors' => $group->pluck('session_id')->unique()->count(),
])
->values()
->sortBy('date')
->toArray();
$referrers = $hits->filter(fn($h) => !empty($h->referrer))
->groupBy(function ($h) {
$url = $h->referrer;
if (str_contains($url, 'google.')) return 'Google';
if (str_contains($url, 'yandex.')) return 'Yandex';
if (str_contains($url, 'vk.com')) return 'VK';
if (str_contains($url, 't.me')) return 'Telegram';
if (str_contains($url, 'ok.ru')) return 'Одноклассники';
if (str_contains($url, 'dzen.ru')) return 'Дзен';
return parse_url($url, PHP_URL_HOST) ?? 'Другое';
})
->map(fn($group) => [
'source' => $group->first()->referrer,
'hits' => $group->count(),
'visitors' => $group->pluck('session_id')->unique()->count(),
])
->sortByDesc('hits')
->values()
->take(10)
->toArray();
$devices = AnalyticsSession::where('started_at', '>=', $from)
->whereIn('id', $hits->pluck('session_id')->unique())
->selectRaw('device_type, COUNT(*) as count')
->groupBy('device_type')
->pluck('count', 'device_type')
->toArray();
return [
'prefix' => $prefix,
'type' => $type,
'label' => $label,
'breadcrumbs' => $breadcrumbs,
'children' => $children,
'exit_pages' => $exitPages,
'total_views' => $totalViews,
'unique_visitors' => $uniqueVisitors,
'top_pages' => $topPages,
'daily_stats' => $dailyStats,
'referrers' => $referrers,
'devices' => $devices,
];
}
private function getExitPages($hits, string $prefix, int $days): array
{
$sessionIds = $hits->pluck('session_id')->unique();
$allHits = AnalyticsHit::where('created_at', '>=', now()->subDays($days)->startOfDay())
->whereIn('session_id', $sessionIds)
->orderBy('session_id')
->orderBy('created_at')
->selectRaw('session_id, url, created_at')
->get()
->groupBy('session_id');
$exitCounts = [];
foreach ($allHits as $sessionId => $sessionHits) {
$sorted = $sessionHits->values();
for ($i = 0; $i < $sorted->count() - 1; $i++) {
$currentUrl = parse_url($sorted[$i]->url, PHP_URL_PATH) ?: '/';
if (str_starts_with($currentUrl, $prefix)) {
$nextUrl = parse_url($sorted[$i + 1]->url, PHP_URL_PATH) ?: '/';
if (!isset($exitCounts[$nextUrl])) {
$exitCounts[$nextUrl] = 0;
}
$exitCounts[$nextUrl]++;
}
}
}
arsort($exitCounts);
return array_slice(array_map(fn($url, $count) => [
'url' => $url,
'count' => $count,
], array_keys($exitCounts), $exitCounts), 0, 10);
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\AppStructure\Models\MainSection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetSectionsStatsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$sections = config('analytics.sections', []);
$hits = AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('url, session_id')
->get();
$grouped = [];
foreach ($hits as $hit) {
$prefix = $this->resolvePrefix($hit->url, $sections);
if (!isset($grouped[$prefix])) {
$grouped[$prefix] = ['views' => 0, 'visitors' => []];
}
$grouped[$prefix]['views']++;
$grouped[$prefix]['visitors'][$hit->session_id] = true;
}
$result = [];
foreach ($grouped as $prefix => $data) {
$config = $sections[$prefix] ?? null;
$result[] = [
'prefix' => $prefix,
'label' => $config['label'] ?? $prefix,
'icon' => $config['icon'] ?? 'folder',
'views' => $data['views'],
'visitors' => count($data['visitors']),
'order' => $config['order'] ?? 99,
];
}
usort($result, fn($a, $b) => $a['order'] <=> $b['order']);
return $result;
}
private function resolvePrefix(string $url, array $sections): string
{
$path = parse_url($url, PHP_URL_PATH) ?: '/';
$segments = explode('/', trim($path, '/'));
$firstSegment = '/' . ($segments[0] ?? '');
if (isset($sections[$firstSegment])) {
return $firstSegment;
}
if ($firstSegment === '/' && $path === '/') {
return '/';
}
// Check main sections for CMS pages
$mainSection = MainSection::where('slug', $segments[0] ?? '')->first();
if ($mainSection) {
return '/page';
}
return '/other';
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Facades\DB;
class GetTopPagesTask
{
public function run(int $days = 7, int $limit = 10): array
{
return AnalyticsHit::select('url')
->selectRaw('COUNT(*) as views')
->selectRaw('COUNT(DISTINCT session_id) as unique_visitors')
->where('created_at', '>=', now()->subDays($days))
->groupBy('url')
->orderByDesc('views')
->limit($limit)
->get()
->toArray();
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Containers\Analytics\UI\WEB\Controllers;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\Analytics\Tasks\GetAnalyticsOverviewTask;
use App\Containers\Analytics\Tasks\GetDailyStatsTask;
use App\Containers\Analytics\Tasks\GetDevicesTask;
use App\Containers\Analytics\Tasks\GetNavigationSectionsTask;
use App\Containers\Analytics\Tasks\GetReferrersTask;
use App\Containers\Analytics\Tasks\GetSectionDetailTask;
use App\Containers\Analytics\Tasks\GetTopPagesTask;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Response;
class AnalyticsController extends Controller
{
public function __construct(
private readonly GetAnalyticsOverviewTask $overviewTask,
private readonly GetTopPagesTask $topPagesTask,
private readonly GetDailyStatsTask $dailyStatsTask,
private readonly GetReferrersTask $referrersTask,
private readonly GetDevicesTask $devicesTask,
private readonly GetNavigationSectionsTask $navigationSectionsTask,
private readonly GetSectionDetailTask $sectionDetailTask,
) {}
public function __invoke(Request $request): Response
{
$days = (int) $request->query('days', 30);
return inertia()->render('Dashboard/Analytics', [
'overview' => $this->overviewTask->run(days: $days),
'topPages' => $this->topPagesTask->run(days: $days),
'dailyStats' => $this->dailyStatsTask->run(days: $days),
'referrers' => $this->referrersTask->run(days: $days),
'devices' => $this->devicesTask->run(days: $days),
'navigation' => $this->navigationSectionsTask->run(days: $days),
'period' => $days,
]);
}
public function section(Request $request): Response
{
$prefix = $request->query('prefix', '/');
$days = (int) $request->query('days', 30);
$section = $this->sectionDetailTask->run(prefix: $prefix, days: $days);
return inertia()->render('Dashboard/AnalyticsSectionDetail', [
'section' => $section,
'period' => $days,
]);
}
public function clear(): RedirectResponse
{
DB::statement('SET FOREIGN_KEY_CHECKS=0');
AnalyticsHit::truncate();
AnalyticsSession::truncate();
AnalyticsDailyStat::truncate();
DB::statement('SET FOREIGN_KEY_CHECKS=1');
return redirect()->route('dashboard.analytics.index')
->with('success', 'Данные аналитики очищены');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Containers\Analytics\UI\WEB\Controllers;
use App\Containers\Analytics\Jobs\RecordVisitJob;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class TrackController extends Controller
{
public function store(Request $request): Response
{
$payload = [
'visitor_id' => $request->input('visitor_id'),
'url' => $request->input('url'),
'referrer' => $request->input('referrer'),
'title' => $request->input('title'),
'screen_resolution' => $request->input('screen_resolution'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'user_id' => auth('web')->id(),
];
$url = parse_url($request->input('url', ''), PHP_URL_QUERY);
if ($url) {
parse_str($url, $queryParams);
$payload['utm_source'] = $queryParams['utm_source'] ?? null;
$payload['utm_medium'] = $queryParams['utm_medium'] ?? null;
$payload['utm_campaign'] = $queryParams['utm_campaign'] ?? null;
}
RecordVisitJob::dispatch($payload)->onQueue('analytics');
return response()->noContent();
}
}
@@ -0,0 +1,8 @@
<?php
use App\Containers\Analytics\UI\WEB\Controllers\TrackController;
use Illuminate\Support\Facades\Route;
Route::post('/track/hit', [TrackController::class, 'store'])
->middleware(['throttle:60,1'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
@@ -0,0 +1,3 @@
<?php
// Route is registered in Dashboard/UI/WEB/Routes/web.php
@@ -431,6 +431,11 @@ Route::middleware(['access-check', 'dashboard.auth', 'dashboard.permission'])->g
Route::put('/{credential}', [IntegrationCredentialsController::class, 'update'])->name('update'); Route::put('/{credential}', [IntegrationCredentialsController::class, 'update'])->name('update');
Route::delete('/{credential}', [IntegrationCredentialsController::class, 'destroy'])->name('destroy'); Route::delete('/{credential}', [IntegrationCredentialsController::class, 'destroy'])->name('destroy');
}); });
// Аналитика
Route::get('/dashboard/analytics', \App\Containers\Analytics\UI\WEB\Controllers\AnalyticsController::class)->name('dashboard.analytics.index');
Route::get('/dashboard/analytics/section', [\App\Containers\Analytics\UI\WEB\Controllers\AnalyticsController::class, 'section'])->name('dashboard.analytics.section');
Route::post('/dashboard/analytics/clear', [\App\Containers\Analytics\UI\WEB\Controllers\AnalyticsController::class, 'clear'])->name('dashboard.analytics.clear');
}); });
@@ -21,7 +21,6 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array public function share(Request $request): array
{ {
// Отключаем SSR для Dashboard роутов
if (str_starts_with($request->path(), 'dashboard')) { if (str_starts_with($request->path(), 'dashboard')) {
config(['inertia.ssr.enabled' => false]); config(['inertia.ssr.enabled' => false]);
} }
@@ -49,7 +48,6 @@ class HandleInertiaRequests extends Middleware
} }
return 'empty'; return 'empty';
}, },
'yandex_metrika_id' => config('services.yandex_metrika.id'),
'flash' => function () use ($request) { 'flash' => function () use ($request) {
return [ return [
'success' => $request->session()->get('success'), 'success' => $request->session()->get('success'),
+2 -8
View File
@@ -87,15 +87,9 @@ class AppServiceProvider extends ServiceProvider
private static function configureFactoryResolution(): void private static function configureFactoryResolution(): void
{ {
Factory::guessFactoryNamesUsing(function (string $modelName) { Factory::guessFactoryNamesUsing(function (string $modelName) {
$appNamespace = 'App\\'; $className = class_basename($modelName);
if (str_starts_with($modelName, $appNamespace.'Models\\')) { return 'Database\\Factories\\'.$className.'Factory';
$modelName = substr($modelName, strlen($appNamespace.'Models\\'));
} else {
$modelName = substr($modelName, strlen($appNamespace));
}
return 'Database\\Factories\\'.$modelName.'Factory';
}); });
} }
@@ -21,6 +21,11 @@ class SyncDashboardPermissions extends AbstractConsoleCommand
'educational_group', 'division', 'educational_group', 'division',
]; ];
private const EXTRA_PERMISSIONS = [
'view_analytics',
'view_any_analytic',
];
private const PREFIXES = [ private const PREFIXES = [
'view_any', 'create', 'update', 'delete', 'restore', 'force_delete', 'view_any', 'create', 'update', 'delete', 'restore', 'force_delete',
]; ];
@@ -46,6 +51,13 @@ class SyncDashboardPermissions extends AbstractConsoleCommand
['name' => 'view_any_contact_widget', 'guard_name' => 'web'], ['name' => 'view_any_contact_widget', 'guard_name' => 'web'],
); );
foreach (self::EXTRA_PERMISSIONS as $name) {
Permission::firstOrCreate(
['name' => $name, 'guard_name' => 'web'],
);
$created++;
}
$roleNames = explode(',', $this->option('role')); $roleNames = explode(',', $this->option('role'));
$allPerms = Permission::where('guard_name', 'web')->get(); $allPerms = Permission::where('guard_name', 'web')->get();
@@ -49,7 +49,6 @@ class HandleInertiaRequests extends Middleware
} }
return 'empty'; return 'empty';
}, },
'yandex_metrika_id' => config('services.yandex_metrika.id'),
]; ];
} }
} }
+7 -1
View File
@@ -37,6 +37,7 @@
"tomatophp/filament-icons": "v1.1.4", "tomatophp/filament-icons": "v1.1.4",
"vkcom/vk-php-sdk": "^5.131", "vkcom/vk-php-sdk": "^5.131",
"webklex/php-imap": "^6.2", "webklex/php-imap": "^6.2",
"whichbrowser/parser": "^2.1",
"xvladqt/faker-lorem-flickr": "^1.0", "xvladqt/faker-lorem-flickr": "^1.0",
"yepsua/filament-range-field": "^0.3.4" "yepsua/filament-range-field": "^0.3.4"
}, },
@@ -91,5 +92,10 @@
} }
}, },
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true "prefer-stable": true,
"repositories": [{
"name": "packagist",
"type": "composer",
"url": "https://repo.packagist.org"
}]
} }
Generated
+64 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "6df5b3167c82bcd51f51f438281adf2b", "content-hash": "b8cca6122813359fd3f1884506e001fa",
"packages": [ "packages": [
{ {
"name": "alxdorosenco/porto-for-laravel", "name": "alxdorosenco/porto-for-laravel",
@@ -11435,6 +11435,69 @@
], ],
"time": "2025-04-25T06:02:37+00:00" "time": "2025-04-25T06:02:37+00:00"
}, },
{
"name": "whichbrowser/parser",
"version": "v2.1.8",
"source": {
"type": "git",
"url": "https://github.com/WhichBrowser/Parser-PHP.git",
"reference": "581d614d686bfbec3529ad60562a5213ac5d8d72"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/WhichBrowser/Parser-PHP/zipball/581d614d686bfbec3529ad60562a5213ac5d8d72",
"reference": "581d614d686bfbec3529ad60562a5213ac5d8d72",
"shasum": ""
},
"require": {
"php": ">=5.4.0",
"psr/cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"cache/array-adapter": "^1.1",
"icomefromthenet/reverse-regex": "0.0.6.3",
"php-coveralls/php-coveralls": "^2.0",
"phpunit/php-code-coverage": "^5.0 || ^7.0",
"phpunit/phpunit": "^6.0 || ^8.0",
"squizlabs/php_codesniffer": "^3.5",
"symfony/yaml": "~3.4 || ~4.0"
},
"suggest": {
"cache/array-adapter": "Allows testing of the caching functionality"
},
"type": "library",
"autoload": {
"psr-4": {
"WhichBrowser\\": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Niels Leenheer",
"email": "niels@leenheer.nl",
"role": "Developer"
}
],
"description": "Useragent sniffing library for PHP",
"homepage": "http://whichbrowser.net",
"keywords": [
"browser",
"sniffing",
"ua",
"useragent"
],
"support": {
"issues": "https://github.com/WhichBrowser/Parser-PHP/issues",
"source": "https://github.com/WhichBrowser/Parser-PHP/tree/v2.1.8"
},
"time": "2024-04-17T12:47:41+00:00"
},
{ {
"name": "xvladqt/faker-lorem-flickr", "name": "xvladqt/faker-lorem-flickr",
"version": "v1.0.0", "version": "v1.0.0",
+88
View File
@@ -0,0 +1,88 @@
<?php
return [
'group_labels' => [
'structure' => 'Структура сайта',
'institute' => 'Институт',
'content' => 'Контент',
'services' => 'Сервисы',
],
'sections' => [
'/' => [
'label' => 'Главная',
'icon' => 'home',
'order' => 0,
],
'/faculties' => [
'label' => 'Факультеты',
'icon' => 'academic-cap',
'order' => 1,
'group' => 'institute',
'children' => [
'model' => \App\Containers\InstituteStructure\Models\Faculty::class,
'name_key' => 'title',
'slug_key' => 'slug',
],
],
'/divisions' => [
'label' => 'Подразделения',
'icon' => 'building',
'order' => 2,
'group' => 'institute',
'children' => [
'model' => \App\Containers\InstituteStructure\Models\Division::class,
'name_key' => 'title',
'slug_key' => 'slug',
],
],
'/news' => [
'label' => 'Новости',
'icon' => 'document',
'order' => 3,
'group' => 'content',
],
'/program' => [
'label' => 'Программы',
'icon' => 'book-open',
'order' => 4,
'group' => 'content',
],
'/schedule' => [
'label' => 'Расписание',
'icon' => 'clock',
'order' => 6,
'group' => 'services',
],
'/additional-education' => [
'label' => 'ДПО',
'icon' => 'academic-cap',
'order' => 7,
'group' => 'content',
'children' => [
'model' => \App\Containers\AdditionalEducation\Models\AdditionalEducation::class,
'name_key' => 'title',
'slug_key' => 'slug',
],
],
'/academic-journals' => [
'label' => 'Журналы',
'icon' => 'beaker',
'order' => 8,
'group' => 'content',
'children' => [
'model' => \App\Containers\Science\Models\AcademicJournal::class,
'name_key' => 'title',
'slug_key' => 'slug',
],
],
'/persons' => [
'label' => 'Сотрудники',
'icon' => 'user',
'order' => 9,
'group' => 'institute',
],
],
];
+1
View File
@@ -173,6 +173,7 @@ return [
App\Providers\RouteServiceProvider::class, App\Providers\RouteServiceProvider::class,
\App\Providers\ForceHttpsServiceProvider::class, \App\Providers\ForceHttpsServiceProvider::class,
\App\Containers\VikonIntegration\Providers\VikonServiceProvider::class, \App\Containers\VikonIntegration\Providers\VikonServiceProvider::class,
\App\Containers\Analytics\Providers\AnalyticsServiceProvider::class,
])->toArray(), ])->toArray(),
/* /*
-4
View File
@@ -31,10 +31,6 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
], ],
'yandex_metrika' => [
'id' => env('YANDEX_METRIKA_ID'),
],
'vk' => [ 'vk' => [
'app_id' => env('VK_APP_ID'), 'app_id' => env('VK_APP_ID'),
'service_key' => env('SERVICE_ACCESS_VK_KEY'), 'service_key' => env('SERVICE_ACCESS_VK_KEY'),
@@ -12,7 +12,7 @@ class EducationalGroupFactory extends Factory
{ {
return [ return [
'title' => $this->faker->words(3, true), 'title' => $this->faker->words(3, true),
'faculty_id' => \App\Containers\InstituteStructure\Models\Faculty::factory(), 'faculty_id' => \App\Containers\InstituteStructure\Models\Faculty::inRandomOrder()->first(),
'education_form_id' => $this->faker->numberBetween(1, 3), 'education_form_id' => $this->faker->numberBetween(1, 3),
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'), 'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
'updated_at' => $this->faker->dateTimeBetween('-1 year', 'now'), 'updated_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
+16 -29
View File
@@ -6,23 +6,13 @@ use App\Containers\Event\Models\EventCategory;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Event>
*/
class EventFactory extends Factory class EventFactory extends Factory
{ {
protected $model = \App\Containers\Event\Models\Event::class; protected $model = \App\Containers\Event\Models\Event::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array public function definition(): array
{ {
$id = $this->faker->numberBetween(1, 1000000);
$title = $this->faker->sentence; $title = $this->faker->sentence;
$slug = Str::slug($title);
$content = array_map(function () { $content = array_map(function () {
return [ return [
'type' => $this->faker->randomElement(['heading', 'paragraph']), 'type' => $this->faker->randomElement(['heading', 'paragraph']),
@@ -32,29 +22,26 @@ class EventFactory extends Factory
], ],
]; ];
}, range(1, $this->faker->numberBetween(1, 5))); }, range(1, $this->faker->numberBetween(1, 5)));
$today = now();
$random_days = rand(1, 25); // Генерируем случайное число дней от 1 до 365 $eventDateStart = $this->faker->dateTimeBetween('-6 months', 'now');
$event_date_start = date('Y-m-d', strtotime($today->format('Y-m-d') . ' +' . $random_days . ' days')); $endDate = date('Y-m-d', strtotime($eventDateStart->format('Y-m-d') . ' +' . rand(1, 30) . ' days'));
$event_time_start = $this->faker->time('H:i'); $eventDateEnd = $this->faker->optional(0.5)->dateTimeBetween(
$address = $this->faker->address; $eventDateStart->format('Y-m-d'),
$is_online = $this->faker->randomElement([true, false]); $endDate
$category_id = EventCategory::inRandomOrder()->first(); );
$created_at = now();
$updated_at = now();
return [ return [
'id' => $id,
'title' => $title, 'title' => $title,
'slug' => $slug, 'slug' => Str::slug($title),
'content' => $content, 'content' => $content,
'event_date_start' => $event_date_start, 'event_date_start' => $eventDateStart->format('Y-m-d'),
'event_time_start' => $event_time_start, 'event_date_end' => $eventDateEnd?->format('Y-m-d'),
'address' => $address, 'event_time_start' => $this->faker->time('H:i'),
'is_online' => $is_online, 'address' => $this->faker->address,
'category_id' => $category_id, 'is_online' => $this->faker->boolean,
'created_at' => $created_at, 'category_id' => EventCategory::factory(),
'updated_at' => $updated_at, 'created_at' => $this->faker->dateTimeBetween('-6 months', 'now'),
'updated_at' => $this->faker->dateTimeBetween('-6 months', 'now'),
]; ];
} }
} }
-1
View File
@@ -18,7 +18,6 @@ class PageFactory extends Factory
return [ return [
'title' => $title, 'title' => $title,
'slug' => $slug, 'slug' => $slug,
'path' => $slug,
'content' => [['type' => 'paragraph', 'data' => ['content' => $this->faker->paragraph]]], 'content' => [['type' => 'paragraph', 'data' => ['content' => $this->faker->paragraph]]],
'is_registered' => false, 'is_registered' => false,
'is_visible' => true, 'is_visible' => true,
+2 -2
View File
@@ -43,12 +43,12 @@ class UserDetailFactory extends Factory
'photo' => "images/01J7TKMCR55KY7ARS2DA6VVAHE.jpg", 'photo' => "images/01J7TKMCR55KY7ARS2DA6VVAHE.jpg",
'academicTitle' => $this->faker->word, 'academicTitle' => $this->faker->word,
'AcademicDegree' => $this->faker->word, 'AcademicDegree' => $this->faker->word,
'education' => $this->faker->sentence, 'education' => [['item' => $this->faker->sentence(6)]],
'awards' => $awards, 'awards' => $awards,
'professDisciplines' => $generateItems(), 'professDisciplines' => $generateItems(),
'professionalRetraining' => $generateItems(), 'professionalRetraining' => $generateItems(),
'professionalDevelopment' => $generateItems(), 'professionalDevelopment' => $generateItems(),
'workExperience' => $this->faker->randomDigit(), 'workExperience' => $generateItems(),
'attendedConferences' => $generateItems(), 'attendedConferences' => $generateItems(),
'publications' => $generateItems(), 'publications' => $generateItems(),
'contactEmail' => $this->faker->unique()->safeEmail, 'contactEmail' => $this->faker->unique()->safeEmail,
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('analytics_sessions', function (Blueprint $table) {
$table->id();
$table->string('visitor_id', 36)->index();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('entry_page');
$table->string('ip', 45);
$table->string('country', 2)->nullable();
$table->string('city')->nullable();
$table->string('utm_source')->nullable();
$table->string('utm_medium')->nullable();
$table->string('utm_campaign')->nullable();
$table->string('browser')->nullable();
$table->string('os')->nullable();
$table->enum('device_type', ['desktop', 'tablet', 'mobile'])->nullable();
$table->timestamp('started_at');
$table->timestamp('last_activity_at');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
$table->index(['visitor_id', 'last_activity_at']);
});
}
public function down(): void
{
Schema::dropIfExists('analytics_sessions');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('analytics_hits', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('session_id');
$table->unsignedBigInteger('user_id')->nullable();
$table->string('url');
$table->string('referrer')->nullable();
$table->string('title')->nullable();
$table->timestamp('created_at');
$table->foreign('session_id')->references('id')->on('analytics_sessions')->cascadeOnDelete();
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
$table->index('url');
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('analytics_hits');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('analytics_daily_stats', function (Blueprint $table) {
$table->id();
$table->date('date');
$table->string('url');
$table->unsignedInteger('views_count')->default(0);
$table->unsignedInteger('unique_visitors')->default(0);
$table->timestamps();
$table->unique(['date', 'url']);
});
}
public function down(): void
{
Schema::dropIfExists('analytics_daily_stats');
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('analytics_sessions', function (Blueprint $table) {
$table->dropColumn(['country', 'city']);
});
Schema::dropIfExists('analytics_geo_cache');
}
public function down(): void
{
Schema::table('analytics_sessions', function (Blueprint $table) {
$table->string('country', 2)->nullable()->after('ip');
$table->string('city')->nullable()->after('country');
});
Schema::create('analytics_geo_cache', function (Blueprint $table) {
$table->id();
$table->string('ip_hash', 64)->unique();
$table->string('country')->nullable();
$table->string('city')->nullable();
$table->timestamp('cached_at');
$table->index('cached_at');
});
}
};
+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 public function run(): void
{ {
$this->call([ $this->call([
RolesSeeder::class,
MockDataSeeder::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\AdmissionPlan;
use App\Containers\Education\Models\DirectionStudy; use App\Containers\Education\Models\DirectionStudy;
use App\Containers\Education\Models\EducationalProgram; 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\Department;
use App\Containers\InstituteStructure\Models\Division; use App\Containers\InstituteStructure\Models\Division;
use App\Containers\InstituteStructure\Models\Faculty; use App\Containers\InstituteStructure\Models\Faculty;
@@ -49,52 +47,61 @@ class MockDataSeeder extends Seeder
// 1. Users & Roles // 1. Users & Roles
$this->createUsers(); $this->createUsers();
// 2. Institute Structure // 2. Institute Structure: 6 факультетов, по 2 кафедры на каждый
$faculties = Faculty::factory()->count(8)->create(); $faculties = Faculty::factory()->count(6)->create();
$departments = Department::factory()->count(15)->create();
$divisions = Division::factory()->count(5)->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 // 3. Pivot: workers_faculties, workers_departments, teachers_departments
$this->attachWorkers($faculties, $departments); $this->attachWorkers($faculties, $departments);
// 4. Article // 4. Article
$categories = Category::factory()->count(20)->create(); $categories = Category::factory()->count(20)->create();
Post::factory()->count(100)->create(); Post::factory()->count(1000)->create();
Tag::factory()->count(30)->create(); Tag::factory()->count(30)->create();
// 5. Events // 5. Education
$eventCategories = EventCategory::factory()->count(8)->create();
Event::factory()->count(50)->create();
// 6. Education
$directionStudies = DirectionStudy::factory()->count(10)->create(); $directionStudies = DirectionStudy::factory()->count(10)->create();
$programs = EducationalProgram::factory()->count(25)->create(); $programs = EducationalProgram::factory()->count(25)
$campaigns = AdmissionCampaign::factory()->count(5)->create(); ->sequence(fn () => ['direction_study_id' => $directionStudies->random()->id])
AdmissionPlan::factory()->count(30)->create(); ->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); $this->attachProgramsToDepartments($programs, $departments);
// 8. Additional Education // 7. Additional Education
$directions = DirectionAdditionalEducation::factory()->count(5)->create(); DirectionAdditionalEducation::factory()->count(5)->create();
$addCategories = AdditionalEducationCategory::factory()->count(12)->create(); AdditionalEducationCategory::factory()->count(12)->create();
AdditionalEducation::factory()->count(20)->create(); AdditionalEducation::factory()->count(20)->create();
// 9. Science // 8. Science
$journals = AcademicJournal::factory()->count(4)->create(); AcademicJournal::factory()->count(4)->create();
JournalIssue::factory()->count(15)->create(); JournalIssue::factory()->count(15)->create();
// 10. Schedule // 9. Schedule
$groups = EducationalGroup::factory()->count(40)->create(); $groups = EducationalGroup::factory()->count(40)->create();
Schedule::factory()->count(90)->create(); Schedule::factory()->count(90)->create();
// 11. Widgets // 10. Widgets: 1 главный слайдер + 5 слайдов
Slider::create([ Slider::create([
'title' => 'Главный слайдер', 'title' => 'Главный слайдер',
'slug' => 'quos-velit-quisquam', 'slug' => 'quos-velit-quisquam',
'is_active' => true, 'is_active' => true,
]); ]);
Slider::factory()->count(4)->create();
$mainSlider = Slider::where('slug', 'quos-velit-quisquam')->first(); $mainSlider = Slider::where('slug', 'quos-velit-quisquam')->first();
Slide::factory()->count(5)->create([ Slide::factory()->count(5)->create([
@@ -103,8 +110,8 @@ class MockDataSeeder extends Seeder
'start_time' => now()->subWeek(), 'start_time' => now()->subWeek(),
'end_time' => now()->addMonth(), 'end_time' => now()->addMonth(),
]); ]);
Slide::factory()->count(15)->create();
// 11. Contact & Reference widgets
ContactWidget::create([ ContactWidget::create([
'title' => 'Главная страница контакты', 'title' => 'Главная страница контакты',
'slug' => 'glavnaia-stranica-kontakty', 'slug' => 'glavnaia-stranica-kontakty',
@@ -130,7 +137,6 @@ class MockDataSeeder extends Seeder
], ],
'is_active' => true, 'is_active' => true,
]); ]);
ContactWidget::factory()->count(2)->create();
PageReferenceList::create([ PageReferenceList::create([
'title' => 'Главная страница ресурсы', 'title' => 'Главная страница ресурсы',
@@ -142,8 +148,8 @@ class MockDataSeeder extends Seeder
], ],
'is_active' => true, 'is_active' => true,
]); ]);
PageReferenceList::factory()->count(4)->create();
$forms = CustomForm::factory()->count(8)->create(); CustomForm::factory()->count(8)->create();
CustomFormResponse::factory()->count(40)->create(); CustomFormResponse::factory()->count(40)->create();
// 12. App Structure // 12. App Structure
@@ -152,10 +158,16 @@ class MockDataSeeder extends Seeder
->count(15) ->count(15)
->sequence(fn () => ['main_section_id' => $mainSections->random()->id]) ->sequence(fn () => ['main_section_id' => $mainSections->random()->id])
->create(); ->create();
Page::factory()
->count(40) $pages = collect();
->sequence(fn () => ['sub_section_id' => $subSections->random()->id]) for ($i = 0; $i < 40; $i++) {
->create(); $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 // 13. Integration Credentials
IntegrationCredential::factory()->count(3)->create(); IntegrationCredential::factory()->count(3)->create();
@@ -188,7 +200,6 @@ class MockDataSeeder extends Seeder
); );
User::factory()->count(50)->create(); User::factory()->count(50)->create();
UserDetail::factory()->count(30)->create(); UserDetail::factory()->count(30)->create();
} }
+1 -1
View File
@@ -57,7 +57,7 @@ services:
volumes: volumes:
- ./:/var/www - ./:/var/www
container_name: ntspi-php-queue container_name: ntspi-php-queue
command: php artisan queue:work --tries=3 --timeout=90 --sleep=3 command: php artisan queue:work --queue=analytics,default --tries=3 --timeout=90 --sleep=3
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+30 -1
View File
@@ -19,9 +19,9 @@
"@preline/overlay": "^1.4.0", "@preline/overlay": "^1.4.0",
"@preline/scrollspy": "^2.0.0", "@preline/scrollspy": "^2.0.0",
"@preline/select": "^2.5.0", "@preline/select": "^2.5.0",
"@rollup/rollup-linux-arm64-gnu": "^4.60.1",
"@vue/server-renderer": "^3.5.12", "@vue/server-renderer": "^3.5.12",
"@vuepic/vue-datepicker": "^11.0.2", "@vuepic/vue-datepicker": "^11.0.2",
"chart.js": "^4.5.1",
"daisyui": "^4.12.14", "daisyui": "^4.12.14",
"flowbite": "^2.5.2", "flowbite": "^2.5.2",
"fslightbox": "^3.4.1", "fslightbox": "^3.4.1",
@@ -31,6 +31,7 @@
"preline": "^1.9.0", "preline": "^1.9.0",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"vite": "^6.3.5", "vite": "^6.3.5",
"vue-chartjs": "^5.3.4",
"vuex": "^4.1.0" "vuex": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -666,6 +667,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1856,6 +1863,18 @@
"node": ">=0.8.0" "node": ">=0.8.0"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -9418,6 +9437,16 @@
} }
} }
}, },
"node_modules/vue-chartjs": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vuex": { "node_modules/vuex": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz", "resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz",
+2
View File
@@ -42,6 +42,7 @@
"@preline/select": "^2.5.0", "@preline/select": "^2.5.0",
"@vue/server-renderer": "^3.5.12", "@vue/server-renderer": "^3.5.12",
"@vuepic/vue-datepicker": "^11.0.2", "@vuepic/vue-datepicker": "^11.0.2",
"chart.js": "^4.5.1",
"daisyui": "^4.12.14", "daisyui": "^4.12.14",
"flowbite": "^2.5.2", "flowbite": "^2.5.2",
"fslightbox": "^3.4.1", "fslightbox": "^3.4.1",
@@ -51,6 +52,7 @@
"preline": "^1.9.0", "preline": "^1.9.0",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"vite": "^6.3.5", "vite": "^6.3.5",
"vue-chartjs": "^5.3.4",
"vuex": "^4.1.0" "vuex": "^4.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {
+132
View File
@@ -0,0 +1,132 @@
<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>
@@ -0,0 +1,184 @@
<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>
@@ -159,4 +159,12 @@ export const menuItems = [
activePrefixes: ['dashboard.integration-credentials'], activePrefixes: ['dashboard.integration-credentials'],
permission: null, permission: null,
}, },
{
key: 'analytics',
label: 'Аналитика',
icon: 'chart-bar',
route: 'dashboard.analytics.index',
activePrefixes: ['dashboard.analytics'],
permission: 'view_analytics',
},
]; ];
@@ -0,0 +1,145 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-foreground">Посещаемость</h3>
<div class="flex bg-layer border border-layer-line rounded-md p-0.5">
<button
@click="metric = 'visitors'"
class="px-2.5 py-1 text-[11px] font-medium rounded transition-colors"
:class="metric === 'visitors' ? 'bg-primary text-white' : 'text-muted-foreground-1 hover:text-foreground'"
>
Посетители
</button>
<button
@click="metric = 'views'"
class="px-2.5 py-1 text-[11px] font-medium rounded transition-colors"
:class="metric === 'views' ? 'bg-primary text-white' : 'text-muted-foreground-1 hover:text-foreground'"
>
Просмотры
</button>
</div>
</div>
<div v-if="data.length === 0" class="text-xs text-muted-foreground-1 py-8 text-center">
Нет данных за выбранный период
</div>
<div v-else-if="data.length === 1" class="py-8 text-center">
<p class="text-3xl font-bold text-foreground">{{ data[0][metric === 'views' ? 'views' : 'visitors'] }}</p>
<p class="text-xs text-muted-foreground-1 mt-1">{{ formatDate(data[0].date) }}</p>
</div>
<div v-else class="relative" style="height: 200px;">
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script>
import { Line } from 'vue-chartjs';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip, Legend);
export default {
name: 'AnalyticsChart',
components: { Line },
props: {
data: { type: Array, required: true },
},
data() {
return { metric: 'visitors' };
},
computed: {
chartData() {
const values = this.data.map(d => this.metric === 'views' ? d.views : d.visitors);
return {
labels: this.data.map(d => this.formatDate(d.date)),
datasets: [
{
label: this.metric === 'views' ? 'Просмотры' : 'Посетители',
data: values,
borderColor: 'rgb(30, 87, 163)',
backgroundColor: 'rgba(30, 87, 163, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: this.data.length > 31 ? 0 : 3,
pointHoverRadius: 5,
pointBackgroundColor: 'white',
pointBorderColor: 'rgb(30, 87, 163)',
pointBorderWidth: 2,
},
],
};
},
chartOptions() {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgb(30, 30, 30)',
titleFont: { size: 11 },
bodyFont: { size: 12 },
padding: 8,
cornerRadius: 6,
displayColors: false,
callbacks: {
title: (items) => {
const idx = items[0].dataIndex;
const d = new Date(this.data[idx].date);
const days = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
return `${d.getDate()}.${d.getMonth() + 1} (${days[d.getDay()]})`;
},
label: (item) => `${this.metric === 'views' ? 'Просмотры' : 'Посетители'}: ${item.formattedValue}`,
},
},
},
scales: {
x: {
grid: { display: false },
ticks: {
font: { size: 10 },
color: '#9ca3af',
maxTicksLimit: this.data.length > 31 ? 10 : 15,
},
},
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.05)' },
ticks: {
font: { size: 10 },
color: '#9ca3af',
precision: 0,
},
},
},
};
},
},
methods: {
formatDate(dateStr) {
const d = new Date(dateStr);
return `${d.getDate()}.${d.getMonth() + 1}`;
},
},
watch: {
metric() {},
},
};
</script>
@@ -0,0 +1,117 @@
<template>
<div>
<div class="flex gap-1 mb-4 bg-layer border border-layer-line rounded-md p-0.5">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1 text-[11px] font-medium rounded transition-colors"
:class="activeTab === tab.key ? 'bg-primary text-white' : 'text-muted-foreground-1 hover:text-foreground'"
>
{{ tab.label }}
</button>
</div>
<div v-if="activeTab === 'devices'">
<div v-if="Object.keys(devices).length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-3">
<div v-for="(count, type) in devices" :key="type" class="flex items-center justify-between">
<div class="flex items-center gap-2">
<DashboardIcon :name="deviceIcon(type)" size="5" class="text-muted-foreground-2" />
<span class="text-xs font-medium text-foreground">{{ deviceLabel(type) }}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 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-12 text-right">{{ getDevicePercent(count) }}%</span>
</div>
</div>
</div>
</div>
<div v-else-if="activeTab === 'browsers'">
<div v-if="Object.keys(browsers).length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-2">
<div v-for="(count, name) in browsers" :key="name" class="flex items-center justify-between">
<span class="text-xs font-medium text-foreground">{{ name }}</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: getBrowserPercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-8 text-right">{{ count }}</span>
</div>
</div>
</div>
</div>
<div v-else>
<div v-if="Object.keys(oses).length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-2">
<div v-for="(count, name) in oses" :key="name" class="flex items-center justify-between">
<span class="text-xs font-medium text-foreground">{{ name }}</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: getOsPercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-8 text-right">{{ count }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'AnalyticsDevices',
components: { DashboardIcon },
props: {
devices: { type: Object, required: true },
browsers: { type: Object, required: true },
oses: { type: Object, required: true },
},
data() {
return {
activeTab: 'devices',
tabs: [
{ key: 'devices', label: 'Устройства' },
{ key: 'browsers', label: 'Браузеры' },
{ key: 'oses', label: 'ОС' },
],
};
},
computed: {
totalDevices() {
return Object.values(this.devices).reduce((a, b) => a + b, 0) || 1;
},
maxBrowser() {
return Math.max(...Object.values(this.browsers), 1);
},
maxOs() {
return Math.max(...Object.values(this.oses), 1);
},
},
methods: {
deviceIcon(type) {
const map = { desktop: 'computer-desktop', mobile: 'device-phone-mobile', tablet: 'device-tablet' };
return map[type] || 'computer-desktop';
},
deviceLabel(type) {
const map = { desktop: 'Десктоп', mobile: 'Мобильные', tablet: 'Планшеты' };
return map[type] || type;
},
getDevicePercent(count) {
return Math.round((count / this.totalDevices) * 100);
},
getBrowserPercent(count) {
return Math.round((count / this.maxBrowser) * 100);
},
getOsPercent(count) {
return Math.round((count / this.maxOs) * 100);
},
},
};
</script>
@@ -0,0 +1,183 @@
<template>
<div class="space-y-0.5">
<div v-for="ms in filteredSections" :key="ms.id">
<!-- Home item -->
<a
v-if="ms.type === 'home'"
:href="sectionUrl('/')"
class="relative flex items-center justify-between py-2 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(ms.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative">
<DashboardIcon name="home" size="5" class="text-primary" />
<span class="text-sm font-medium text-foreground">{{ ms.title }}</span>
</div>
<div class="flex items-center gap-1.5 relative">
<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>
{{ ms.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>
{{ ms.visitors }}
</span>
</div>
</a>
<!-- Regular section -->
<template v-else>
<a
:href="sectionUrl('/' + ms.slug)"
class="relative flex items-center justify-between py-2 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(ms.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative min-w-0">
<button
v-if="ms.sub_sections && ms.sub_sections.length"
@click.stop.prevent="toggle(ms.id)"
class="p-0.5 rounded hover:bg-muted-hover transition-colors"
>
<svg
class="w-3 h-3 text-muted-foreground-2 transition-transform"
:class="{ 'rotate-90': expanded[ms.id] }"
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>
</button>
<span v-else class="w-3"></span>
<span class="text-sm font-medium text-foreground truncate">{{ ms.title }}</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>
{{ ms.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>
{{ ms.visitors }}
</span>
</div>
</a>
<!-- Sub Sections (collapsible) -->
<div v-if="expanded[ms.id] && ms.sub_sections" class="ml-6 mt-1 space-y-0.5">
<div v-for="ss in ms.sub_sections" :key="ss.id">
<a
:href="sectionUrl('/' + ms.slug + '/' + ss.slug)"
class="relative flex items-center justify-between py-1.5 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(ss.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative min-w-0">
<button
v-if="ss.pages && ss.pages.length"
@click.stop.prevent="toggleMs(ms.id, ss.id)"
class="p-0.5 rounded hover:bg-muted-hover transition-colors"
>
<svg
class="w-2.5 h-2.5 text-muted-foreground-2 transition-transform"
:class="{ 'rotate-90': expandedMs[ms.id + '_' + ss.id] }"
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>
</button>
<span v-else class="w-2.5"></span>
<span class="text-xs font-medium text-foreground truncate">{{ ss.title }}</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>
{{ ss.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>
{{ ss.visitors }}
</span>
</div>
</a>
<!-- Pages (collapsible) -->
<div v-if="expandedMs[ms.id + '_' + ss.id] && ss.pages" class="ml-5 mt-0.5 space-y-0.5">
<a
v-for="page in ss.pages"
:key="page.id"
:href="`/dashboard/analytics/section?prefix=${encodeURIComponent(page.url)}&days=${period}`"
class="relative flex items-center justify-between py-1 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(page.views) + '%' }"
></div>
<div class="flex items-center gap-1.5 relative min-w-0">
<DashboardIcon name="document" size="4" class="text-muted-foreground-2 flex-shrink-0" />
<span class="text-[11px] text-foreground truncate">{{ page.title }}</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>
{{ page.views }}
</span>
</div>
</a>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'AnalyticsNavigationSections',
components: { DashboardIcon },
props: {
navigation: { type: Object, required: true },
period: { type: Number, default: 30 },
group: { type: String, default: null },
},
data() {
return {
expanded: {},
expandedMs: {},
};
},
computed: {
filteredSections() {
if (!this.group) return this.navigation.sections;
return this.navigation.sections.filter(s => s.group === this.group);
},
maxViews() {
const views = this.filteredSections.map(s => s.views);
return Math.max(...views, 1);
},
},
methods: {
getWidth(views) {
return Math.max((views / this.maxViews) * 100, 2);
},
toggle(id) {
this.expanded[id] = !this.expanded[id];
},
toggleMs(msId, ssId) {
const key = msId + '_' + ssId;
this.expandedMs[key] = !this.expandedMs[key];
},
sectionUrl(prefix) {
return route('dashboard.analytics.section', { prefix, days: this.period });
},
},
};
</script>
@@ -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>
@@ -0,0 +1,45 @@
<template>
<div class="flex items-center gap-2">
<div class="flex bg-layer border border-layer-line rounded-lg p-0.5">
<button
v-for="preset in presets"
:key="preset.value"
@click="selectPeriod(preset.value)"
class="px-3 py-1 text-xs font-medium rounded-md transition-colors"
:class="modelValue === preset.value
? 'bg-primary text-white'
: 'text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover'"
>
{{ preset.label }}
</button>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsPeriodFilter',
props: {
modelValue: {
type: Number,
default: 30,
},
},
emits: ['update:modelValue'],
data() {
return {
presets: [
{ label: 'Сегодня', value: 0 },
{ label: '7 дней', value: 7 },
{ label: '30 дней', value: 30 },
{ label: 'Все', value: 365 },
],
};
},
methods: {
selectPeriod(value) {
this.$emit('update:modelValue', value);
},
},
};
</script>
@@ -0,0 +1,36 @@
<template>
<div class="space-y-2">
<div v-if="items.length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-for="item in items" :key="item.source" class="group">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-foreground truncate">{{ item.source }}</span>
<span class="text-xs text-muted-foreground-1 ml-2 flex-shrink-0">{{ item.hits }}</span>
</div>
<div class="h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div
class="h-full bg-primary/60 rounded-full transition-all"
:style="{ width: getWidth(item.hits) + '%' }"
></div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsReferrers',
props: {
items: { type: Array, required: true },
},
computed: {
maxHits() {
return Math.max(...this.items.map(i => i.hits), 1);
},
},
methods: {
getWidth(hits) {
return Math.max((hits / this.maxHits) * 100, 2);
},
},
};
</script>
@@ -0,0 +1,51 @@
<template>
<div>
<div v-if="items.length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-1">
<a
v-for="item in items"
:key="item.prefix"
:href="sectionUrl(item.prefix)"
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: getWidth(item.views) + '%' }"
></div>
<div class="flex items-center gap-2 min-w-0 flex-1 relative">
<span class="text-xs font-medium text-foreground">{{ item.label }}</span>
<svg class="w-3 h-3 text-muted-foreground-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" 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>
<div class="flex items-center gap-3 flex-shrink-0 relative">
<span class="text-[11px] text-muted-foreground-1">{{ item.views }}</span>
<span class="text-[10px] text-muted-foreground-2">{{ item.visitors }} uniq</span>
</div>
</a>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsSections',
props: {
items: { type: Array, required: true },
period: { type: Number, default: 30 },
},
computed: {
maxViews() {
return Math.max(...this.items.map(i => i.views), 1);
},
},
methods: {
getWidth(views) {
return Math.max((views / this.maxViews) * 100, 2);
},
sectionUrl(prefix) {
return route('dashboard.analytics.section', { prefix, days: this.period });
},
},
};
</script>
@@ -0,0 +1,50 @@
<template>
<div class="space-y-1.5">
<div v-if="pages.length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div
v-for="page in pages"
:key="page.url"
class="relative group"
>
<div class="absolute inset-0 bg-primary/5 rounded-md" :style="{ width: getWidth(page.views) + '%' }"></div>
<div class="relative flex items-center justify-between py-1.5 px-2 rounded-md">
<div class="flex items-center gap-2 min-w-0 flex-1">
<a
:href="page.url"
target="_blank"
class="text-[11px] font-medium text-foreground truncate hover:text-primary transition-colors"
:title="page.url"
>
{{ page.url }}
</a>
<svg class="w-3 h-3 text-muted-foreground-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
<div class="flex items-center gap-3 flex-shrink-0 ml-2">
<span class="text-[11px] text-muted-foreground-1">{{ page.views }}</span>
<span class="text-[10px] text-muted-foreground-2">{{ page.unique_visitors }} uniq</span>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsTopPages',
props: {
pages: { type: Array, required: true },
},
computed: {
maxViews() {
return Math.max(...this.pages.map(p => p.views), 1);
},
},
methods: {
getWidth(views) {
return Math.max((views / this.maxViews) * 100, 2);
},
},
};
</script>
@@ -0,0 +1,73 @@
<template>
<transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="visible"
class="fixed bottom-0 inset-x-0 z-50 p-4 sm:p-6"
>
<div class="max-w-3xl mx-auto bg-layer border border-layer-line rounded-lg shadow-lg p-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex-1">
<h3 class="text-sm font-semibold text-foreground mb-1">Cookie и аналитика</h3>
<p class="text-xs text-muted-foreground-1 leading-relaxed">
Мы собираем анонимную статистику посещений для улучшения качества сайта.
Данные не передаются третьим лицам.
<a :href="route('page.view', { path: 'privacy-policy' })" class="underline hover:text-primary">
Подробнее
</a>
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button
@click="decline"
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"
>
Отклонить
</button>
<button
@click="accept"
class="px-3 py-1.5 text-xs font-medium text-white bg-primary hover:bg-primary/90 rounded-md transition-colors"
>
Принять
</button>
</div>
</div>
</div>
</div>
</transition>
</template>
<script>
import { getConsent } from '@/services/analytics.js';
export default {
name: 'ConsentBanner',
data() {
return {
visible: false,
};
},
mounted() {
const consent = getConsent();
if (!consent) {
this.visible = true;
}
},
methods: {
accept() {
localStorage.setItem('analytics_consent', 'granted');
this.visible = false;
},
decline() {
localStorage.setItem('analytics_consent', 'denied');
this.visible = false;
},
},
};
</script>
+12 -13
View File
@@ -12,6 +12,7 @@ import cookieMixin from "@/mixins/cookieMixin.js";
import {helpers} from "@/mixins/Helpers.js"; import {helpers} from "@/mixins/Helpers.js";
import store from '@/store/index.js'; import store from '@/store/index.js';
import '@vuepic/vue-datepicker/dist/main.css'; import '@vuepic/vue-datepicker/dist/main.css';
import { initAnalytics } from '@/services/analytics.js';
// Load TinyMCE globally before app initialization // Load TinyMCE globally before app initialization
const loadTinyMCE = () => { const loadTinyMCE = () => {
@@ -36,10 +37,15 @@ loadTinyMCE().then(() => {
createInertiaApp({ createInertiaApp({
resolve: name => { resolve: name => {
const pages = import.meta.glob('./Pages/**/*.vue') const pages = import.meta.glob('./Pages/**/*.vue')
return pages[`./Pages/${name}.vue`]() const page = pages[`./Pages/${name}.vue`]
if (!page) {
console.error(`Page not found: ${name}`)
return pages['./Pages/Error.vue']()
}
return page()
}, },
setup({ el, App, props, plugin }) { setup({ el, App, props, plugin }) {
return createSSRApp({ render: () => h(App, props) }) const app = createSSRApp({ render: () => h(App, props) })
.use(plugin) .use(plugin)
.use(store) .use(store)
.mixin(linksReform) .mixin(linksReform)
@@ -47,21 +53,14 @@ loadTinyMCE().then(() => {
.mixin(helpers) .mixin(helpers)
.use(ZiggyVue) .use(ZiggyVue)
.mount(el); .mount(el);
initAnalytics();
return app;
}, },
progress: { progress: {
color: '#1E57A3', color: '#1E57A3',
delay: 250, delay: 250,
}, },
}); });
router.on('navigate', (event) => {
const metrikaId = event.detail.page.props.yandex_metrika_id;
if (metrikaId && typeof ym === 'function') {
ym(metrikaId, 'hit', window.location.href, {
title: document.title,
referer: event.detail.page.props.ziggy?.previous_url || document.referrer,
});
}
});
}); });
+68
View File
@@ -0,0 +1,68 @@
import { router } from '@inertiajs/vue3'
const CONSENT_KEY = 'analytics_consent'
const VISITOR_KEY = '_vid'
function getVisitorId() {
let vid = localStorage.getItem(VISITOR_KEY)
if (!vid) {
vid = crypto.randomUUID()
localStorage.setItem(VISITOR_KEY, vid)
}
return vid
}
function isExcludedPath() {
const path = window.location.pathname
return path.startsWith('/dashboard') || path.startsWith('/admin')
}
function sendPayload(payload) {
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' })
if (navigator.sendBeacon) {
const sent = navigator.sendBeacon('/api/track/hit', blob)
if (sent) return
}
fetch('/api/track/hit', {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {})
}
export function getConsent() {
return localStorage.getItem(CONSENT_KEY)
}
export function trackCurrentPage() {
if (typeof window === 'undefined') return
if (getConsent() !== 'granted') return
if (isExcludedPath()) return
sendPayload({
visitor_id: getVisitorId(),
url: window.location.pathname + window.location.search,
referrer: document.referrer || null,
screen_resolution: `${window.screen.width}x${window.screen.height}`,
title: document.title,
timestamp: Date.now(),
})
}
let listenerRegistered = false
export function initAnalytics() {
if (typeof window === 'undefined') return
if (!listenerRegistered) {
listenerRegistered = true
router.on('navigate', () => {
setTimeout(trackCurrentPage, 100)
})
}
window._ntspiTrackHit = trackCurrentPage
}
+37 -22
View File
@@ -54,28 +54,43 @@
<body class=""> <body class="">
@inertia @inertia
@if(config('services.yandex_metrika.id') && app()->environment('production')) <div id="consent-banner" style="display:none; position:fixed; bottom:0; inset-x:0; z-index:9999; padding:1rem;">
<!-- Yandex.Metrika counter --> <div style="max-width:48rem; margin:0 auto; background:#fff; border:1px solid #e5e7eb; border-radius:0.5rem; box-shadow:0 4px 6px -1px rgb(0 0 0 / 0.1); padding:1.25rem; display:flex; flex-direction:column; gap:1rem; align-items:flex-start;" class="sm:flex-row sm:items-center sm:justify-between">
<script type="text/javascript"> <div style="flex:1;">
(function(m,e,t,r,i,k,a){ <h3 style="font-size:0.875rem; font-weight:600; color:#111827; margin:0 0 0.25rem;">Cookie и аналитика</h3>
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)}; <p style="font-size:0.75rem; color:#6b7280; line-height:1.5; margin:0;">
m[i].l=1*new Date(); Мы собираем анонимную статистику посещений для улучшения качества сайта. Данные не передаются третьим лицам.
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }} </p>
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a) </div>
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js', 'ym'); <div style="display:flex; gap:0.5rem; flex-shrink:0;">
<button onclick="declineConsent()" style="padding:0.375rem 0.75rem; font-size:0.75rem; font-weight:500; color:#6b7280; background:transparent; border:1px solid #e5e7eb; border-radius:0.375rem; cursor:pointer;">Отклонить</button>
ym({{ config('services.yandex_metrika.id') }}, 'init', { <button onclick="acceptConsent()" style="padding:0.375rem 0.75rem; font-size:0.75rem; font-weight:500; color:#fff; background:#1E57A3; border:none; border-radius:0.375rem; cursor:pointer;">Принять</button>
defer: true, </div>
webvisor:true, </div>
clickmap:true, </div>
ecommerce:"dataLayer", <script>
accurateTrackBounce:true, (function() {
trackLinks:true var KEY = 'analytics_consent';
}); var banner = document.getElementById('consent-banner');
</script> if (!banner) return;
<noscript><div><img src="https://mc.yandex.ru/watch/{{ config('services.yandex_metrika.id') }}" style="position:absolute; left:-9999px;" alt="" /></div></noscript> var path = window.location.pathname;
<!-- /Yandex.Metrika counter --> if (path.startsWith('/dashboard') || path.startsWith('/admin')) return;
@endif if (!localStorage.getItem(KEY)) {
banner.style.display = 'block';
}
})();
function acceptConsent() {
localStorage.setItem('analytics_consent', 'granted');
document.getElementById('consent-banner').style.display = 'none';
if (typeof window._ntspiTrackHit === 'function') {
window._ntspiTrackHit();
}
}
function declineConsent() {
localStorage.setItem('analytics_consent', 'denied');
document.getElementById('consent-banner').style.display = 'none';
}
</script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -1 +1,3 @@
<?php <?php
require base_path('app/Containers/Analytics/UI/WEB/Routes/api.php');