Przeglądaj źródła

Improve user data report to allow custom date range

BrettonYe 1 rok temu
rodzic
commit
f280b76b6d

+ 95 - 43
app/Http/Controllers/Admin/ReportController.php

@@ -8,8 +8,11 @@ use App\Models\NodeDailyDataFlow;
 use App\Models\NodeHourlyDataFlow;
 use App\Models\Order;
 use App\Models\User;
+use App\Models\UserDailyDataFlow;
 use App\Models\UserDataFlowLog;
+use App\Models\UserHourlyDataFlow;
 use Carbon\Carbon;
+use Carbon\CarbonPeriod;
 use DB;
 use Illuminate\Contracts\View\View;
 use Illuminate\Http\Request;
@@ -48,54 +51,103 @@ class ReportController extends Controller
     {
         $uid = $request->input('uid');
         $username = $request->input('username');
-        if ($uid) {
-            $user = User::find($uid);
-        } elseif ($username) {
-            $user = User::whereUsername($username)->first();
-        }
+        $user = $uid ? User::find($uid) : ($username ? User::whereUsername($username)->first() : null);
+
+        $data = [
+            'start_date' => Carbon::parse(UserDailyDataFlow::whereNotNull('node_id')->orderBy('created_at')->value('created_at'))->format('Y-m-d'),
+        ];
+
+        if ($user) {
+            $hourlyDate = $request->input('hour_date') ? Carbon::parse($request->input('hour_date')) : now();
+            $startDate = $request->input('start') ? Carbon::parse($request->input('start')) : now()->startOfMonth();
+            $endDate = $request->input('end') ? Carbon::parse($request->input('end'))->endOfDay() : now();
 
-        $data = null;
-        if (isset($user)) {
             $currentTime = now();
-            $currentDay = $currentTime->day;
-            $currentHour = $currentTime->hour;
 
-            // 用户当前小时在各线路消耗流量
-            $currentHourFlow = $user->dataFlowLogs()->where('log_time', '>=', $currentTime->startOfHour()->timestamp)->with('node:id,name')->groupBy('node_id')->selectRaw('node_id, log_time, sum(u + d) as total')->get()->map(fn ($item) => [
-                'id' => $item->node_id,
-                'name' => $item->node->name,
-                'time' => $currentHour,
-                'total' => round($item->total / MiB, 2),
-            ]);
+            $mapFlow = static function ($item, $timeKey = 'hour') {
+                $time = $item->$timeKey ?? ($timeKey === 'date' ? Carbon::parse($item->created_at)->format('m-d') : $item->created_at->hour);
+
+                return [
+                    'id' => $item->node_id,
+                    'name' => $item->node->name,
+                    'time' => $time,
+                    'total' => round(($item->total ?? $item->u + $item->d) / (1024 * 1024), 2),
+                ];
+            };
+
+            $todayData = null;
+
+            // 处理今天的数据
+            if ($hourlyDate->isToday() || $endDate->isToday()) {
+                $todayHoursFlow = $user->hourlyDataFlows()
+                    ->whereNotNull('node_id')
+                    ->whereDate('created_at', $currentTime)
+                    ->with('node:id,name')
+                    ->selectRaw('node_id, HOUR(created_at) as hour, u + d as total')
+                    ->get();
+
+                $currentHourFlow = $user->dataFlowLogs()
+                    ->where('log_time', '>=', $currentTime->startOfHour()->timestamp)
+                    ->with('node:id,name')
+                    ->groupBy('node_id')
+                    ->selectRaw('node_id, ? as hour, sum(u + d) as total', [$currentTime->hour])
+                    ->get();
+
+                $todayData = $todayHoursFlow->concat($currentHourFlow)
+                    ->groupBy('node_id')
+                    ->map(function ($items) use ($mapFlow) {
+                        $hourlyData = $items->mapWithKeys(fn ($item) => [$item->hour => $mapFlow($item)]);
+                        $totalFlow = $items->sum('total');
+
+                        return [
+                            'hourly' => $hourlyData,
+                            'daily' => $mapFlow((object) [
+                                'node_id' => $items->first()->node_id,
+                                'node' => $items->first()->node,
+                                'created_at' => Carbon::today(),
+                                'total' => $totalFlow,
+                            ], 'date'),
+                        ];
+                    });
+            }
 
-            $hoursFlow = $user->hourlyDataFlows()->whereNotNull('node_id')->whereDate('created_at', $currentTime)->with('node:id,name')->selectRaw('node_id, HOUR(created_at) as hour, u + d as total')->get()->map(fn ($item) => [
-                'id' => $item->node_id,
-                'name' => $item->node->name,
-                'time' => (int) $item->hour,
-                'total' => round($item->total / MiB, 2),
-            ]); // 用户今天各小时在各线路消耗流量
+            // 处理小时数据
+            if ($hourlyDate->isToday() && $todayData) {
+                $hourlyFlows = $todayData->flatMap(fn ($item) => $item['hourly'])->values();
+            } else {
+                $hourlyFlows = $user->hourlyDataFlows()
+                    ->whereNotNull('node_id')
+                    ->whereDate('created_at', $hourlyDate)
+                    ->with('node:id,name')
+                    ->selectRaw('node_id, HOUR(created_at) as hour, u + d as total')
+                    ->get()
+                    ->map(fn ($item) => $mapFlow($item));
+            }
 
-            $daysFlow = $user->dailyDataFlows()->whereNotNull('node_id')->whereMonth('created_at', $currentTime)->with('node:id,name')->selectRaw('node_id, DAY(created_at) as day, u + d as total')->get()->map(fn ($item) => [
-                'id' => $item->node_id,
-                'name' => $item->node->name,
-                'time' => (int) $item->day,
-                'total' => round($item->total / MiB, 2),
-            ]);
+            // 处理每日数据
+            $dailyFlows = $user->dailyDataFlows()
+                ->whereNotNull('node_id')
+                ->whereBetween('created_at', [$startDate, $endDate])
+                ->with('node:id,name')
+                ->selectRaw('node_id, DATE_FORMAT(created_at, "%m-%d") as date, u + d as total')
+                ->get()
+                ->map(fn ($item) => $mapFlow($item, 'date'));
+
+            if ($endDate->isToday() && $todayData) {
+                $dailyFlows = $dailyFlows->concat($todayData->map(fn ($item) => $item['daily']));
+            }
 
-            $currentDayFlow = collect($currentHourFlow)->merge($hoursFlow)->groupBy('id')->map(fn ($items) => [
-                'id' => $items->first()['id'],
-                'name' => $items->first()['name'],
-                'time' => $currentDay,
-                'total' => round($items->sum('total'), 2),
-            ])->values();
-
-            $data = [
-                'hours' => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23],
-                'days' => range(1, $currentDay),
-                'nodes' => collect([$currentDayFlow, $daysFlow])->collapse()->pluck('name', 'id')->unique()->toArray(),
-                'hourlyFlows' => array_merge($hoursFlow->toArray(), $currentHourFlow->toArray()),
-                'dailyFlows' => array_merge($daysFlow->toArray(), $currentDayFlow->toArray()),
-            ];
+            $data = array_merge($data, [
+                'hours' => range(0, 23),
+                'days' => collect(CarbonPeriod::create($startDate, $endDate))->map(fn ($date) => $date->format('m-d')),
+                'nodes' => $hourlyFlows->concat($dailyFlows)->pluck('name', 'id')->unique()->toArray(),
+                'hourlyFlows' => $hourlyFlows->toArray(),
+                'dailyFlows' => $dailyFlows->toArray(),
+                'hour_dates' => UserHourlyDataFlow::selectRaw('DISTINCT DATE(created_at) as date')
+                    ->orderByDesc('date')
+                    ->pluck('date')
+                    ->toArray(),
+            ]);
         }
 
         return view('admin.report.userDataAnalysis', compact('data'));
@@ -124,7 +176,7 @@ class ReportController extends Controller
         }
 
         $data = [
-            'hours' => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23],
+            'hours' => range(0, 23),
             'start_date' => Carbon::parse(NodeDailyDataFlow::orderBy('created_at')->value('created_at'))->format('Y-m-d'), // 数据库里最早的日期
         ];
 

+ 4 - 1
app/Providers/AppServiceProvider.php

@@ -9,6 +9,8 @@ use Illuminate\Support\ServiceProvider;
 use Schema;
 use URL;
 
+use function config;
+
 class AppServiceProvider extends ServiceProvider
 {
     /**
@@ -16,10 +18,11 @@ class AppServiceProvider extends ServiceProvider
      */
     public function register(): void
     {
-        if ($this->app->isLocal() && \config('app.debug')) {
+        if ($this->app->isLocal() && config('app.debug')) {
             $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
             $this->app->register(TelescopeServiceProvider::class);
             $this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
+            $this->app->register(\Barryvdh\Debugbar\ServiceProvider::class);
         }
         if (File::exists(base_path().'/.env') && Schema::hasTable('config') && DB::table('config')->exists()) {
             $this->app->register(SettingServiceProvider::class);

+ 11 - 11
config/tasks.php

@@ -1,22 +1,22 @@
 <?php
 
 return [
-    'chunk' => env('TASKS_CHUNK', 1000), // 大数据量修改,分段处理,减少内存使用
+    'chunk' => env('TASKS_CHUNK', 3000), // 大数据量修改,分段处理,减少内存使用
     'clean' => [
-        'node_daily_logs' => env('TASKS_NODE_DAILY_LOGS', '-25 month'), // 清除节点每天流量数据日志
-        'node_hourly_logs' => env('TASKS_NODE_HOURLY_LOGS', '-3 days'), // 清除节点每小时流量数据日志
-        'notification_logs' => env('TASKS_NOTIFICATION_LOGS', '-1 month'), // 清理通知日志
+        'notification_logs' => env('TASKS_NOTIFICATION_LOGS', '-18 months'), // 清理通知日志
+        'node_daily_logs' => env('TASKS_NODE_DAILY_LOGS', '-13 months'), // 清除节点每天流量数据日志
+        'node_hourly_logs' => env('TASKS_NODE_HOURLY_LOGS', '-2 weeks'), // 清除节点每小时流量数据日志
         'node_heartbeats' => env('TASKS_NODE_HEARTBEATS', '-30 minutes'), // 清除节点负载信息日志
-        'node_online_logs' => env('TASKS_NODE_ONLINE_LOGS', '-1 hour'), // 清除节点在线用户数日志
+        'node_online_logs' => env('TASKS_NODE_ONLINE_LOGS', '-2 weeks'), // 清除节点在线用户数日志
         'payments' => env('TASKS_PAYMENTS', '-1 year'), // 清理在线支付日志
-        'rule_logs' => env('TASKS_RULE_lOGS', '-3 month'), // 清理审计触发日志
+        'rule_logs' => env('TASKS_RULE_lOGS', '-3 months'), // 清理审计触发日志
         'node_online_ips' => env('TASKS_NODE_ONLINE_IPS', '-1 week'), // 清除用户连接IP
-        'user_baned_logs' => env('TASKS_USER_BANED_LOGS', '-3 month'), // 清除用户封禁日志
-        'user_daily_logs_nodes' => env('TASKS_USER_DAILY_LOGS_NODES', '-1 month'), // 清除用户各节点的每天流量数据日志
-        'user_daily_logs_total' => env('TASKS_USER_DAILY_LOGS_TOTAL', '-3 month'), // 清除用户节点总计的每天流量数据日志
+        'user_baned_logs' => env('TASKS_USER_BANED_LOGS', '-3 months'), // 清除用户封禁日志
+        'user_daily_logs_nodes' => env('TASKS_USER_DAILY_LOGS_NODES', '-36 days'), // 清除用户各节点的每天流量数据日志
+        'user_daily_logs_total' => env('TASKS_USER_DAILY_LOGS_TOTAL', '-3 months'), // 清除用户节点总计的每天流量数据日志
         'user_hourly_logs' => env('TASKS_USER_HOURLY_LOGS', '-3 days'), // 清除用户每时各流量数据日志 最少值为 2
-        'login_logs' => env('TASKS_LOGIN_LOGS', '-3 month'), // 清除用户登陆日志
-        'subscribe_logs' => env('TASKS_SUBSCRIBE_LOGS', '-1 month'), // 清理用户订阅请求日志
+        'login_logs' => env('TASKS_LOGIN_LOGS', '-3 months'), // 清除用户登陆日志
+        'subscribe_logs' => env('TASKS_SUBSCRIBE_LOGS', '-2 months'), // 清理用户订阅请求日志
         'traffic_logs' => env('TASKS_TRAFFIC_LOGS', '-3 days'), // 清除用户流量日志
         'unpaid_orders' => env('UNPAID_ORDERS', '-1 year'), // 清除用户流量日志
     ],

+ 8 - 8
resources/lang/de/notification.php

@@ -9,18 +9,18 @@ return [
     'active_email' => 'Bitte verifizieren Sie innerhalb von 30 Minuten',
     'attribute' => 'Benachrichtigung',
     'block_report' => 'Blockierungsbericht:',
-    'close_ticket' => 'Ticket :id: :title wurde geschlossen',
-    'data_anomaly' => 'Warnung: Datenanomalie',
-    'data_anomaly_content' => 'Benutzer :id: [Upload: :upload | Download: :download | Gesamt: :total] in der letzten Stunde',
+    'close_ticket' => 'Ticket [ID: :id, Titel: :title] wurde geschlossen',
+    'data_anomaly' => 'Warnung: Datenanomalie bei Benutzer',
+    'data_anomaly_content' => 'Benutzer [ID: :id], Datenverbrauch in der letzten Stunde: [Upload: :upload, Download: :download, Gesamt: :total]',
     'details' => 'Einzelheiten anzeigen',
     'details_btn' => 'Bitte klicken Sie auf die Schaltfläche unten, um die Einzelheiten anzuzeigen.',
-    'ding_bot_limit' => 'Jeder Bot darf maximal 20 Nachrichten pro Minute in die Gruppe senden. Bei Überschreiten dieses Limits wird eine Drosselung von 10 Minuten angewendet.',
+    'ding_bot_limit' => 'Jeder Bot darf maximal 20 Nachrichten pro Minute senden. Bei Überschreiten wird eine 10-minütige Drosselung angewendet.',
     'empty' => 'Sie haben keine neuen Nachrichten',
     'error' => '[:channel] Nachrichtenschub mit Ausnahme: :reason',
     'get_access_token_failed' => 'Fehler beim Abrufen des Zugriffstokens!\nMit Anforderungsparametern: :body',
     'into_maintenance' => 'Automatisch in den Wartungsmodus wechseln',
     'new' => '{1} Sie haben :num neue Nachricht|[1,*] Sie haben :num neue Nachrichten',
-    'new_ticket' => 'Neues Ticket erhalten: :title',
+    'new_ticket' => 'Sie haben ein neues Ticket erhalten: [Titel: :title], bitte klicken Sie, um Details anzuzeigen.',
     'next_check_time' => 'Nächste Knotensperrungserkennung: :time',
     'node' => [
         'download' => 'Download',
@@ -35,13 +35,13 @@ return [
     'node_renewal_content' => 'Die folgenden Knoten stehen kurz vor dem Ablauf. Bitte verlängern Sie vor Ablauf, um Unterbrechungen des Dienstes zu vermeiden.',
     'payment_received' => 'Zahlung erhalten, Betrag: :amount. Bestelldetails anzeigen',
     'reply_ticket' => 'Ticket beantwortet: :title',
-    'reset_failed' => '[Tägliche Aufgabe] Benutzer :uid - :username Datenrücksetzung fehlgeschlagen',
+    'reset_failed' => '[Tägliche Aufgabe] Datenrücksetzung für Benutzer [ID: :uid, Benutzername: :username] fehlgeschlagen',
     'serverChan_exhausted' => 'Das heutige Limit wurde erschöpft!',
     'serverChan_limit' => 'Frequenz pro Minute zu hoch. Bitte optimieren Sie die Benachrichtigungseinstellungen!',
     'sign_failed' => 'Die sichere Signaturprüfung ist fehlgeschlagen',
     'ticket_content' => 'Ticketinhalt:',
-    'traffic_remain' => ':percent% des Datenvolumens verbraucht, bitte beachten',
-    'traffic_tips' => 'Bitte beachten Sie das Datum der Datenrücksetzung und nutzen Sie das Datenvolumen rational, oder erneuern Sie es nach dem Verbrauch',
+    'traffic_remain' => 'Sie haben :percent% Ihres Datenvolumens verbraucht, bitte den verbleibenden Verbrauch sorgfältig planen.',
+    'traffic_tips' => 'Beachten Sie das Datum der Datenrücksetzung und planen Sie den Verbrauch entsprechend, oder erneuern Sie das Volumen nach dem Verbrauch.',
     'traffic_warning' => 'Warnung: Datenverbrauch',
     'verification' => 'Ihr Verifizierungscode lautet:',
     'verification_account' => 'Konto-Verifizierung',

+ 8 - 8
resources/lang/en/notification.php

@@ -9,18 +9,18 @@ return [
     'active_email' => 'Please complete verification within 30 minutes',
     'attribute' => 'Notification',
     'block_report' => 'Block report:',
-    'close_ticket' => 'Ticket :id: :title closed',
-    'data_anomaly' => 'Data anomaly user warning',
-    'data_anomaly_content' => 'User :id: [Upload: :upload | Download: :download | Total: :total] in the last hour',
+    'close_ticket' => 'Ticket [ID: :id, Title: :title] has been closed',
+    'data_anomaly' => 'User data anomaly alert',
+    'data_anomaly_content' => 'User [ID: :id], data usage in the last hour: [Upload: :upload, Download: :download, Total: :total]',
     'details' => 'View Details',
     'details_btn' => 'Please click the button below to view the details.',
-    'ding_bot_limit' => 'Each bot can send up to 20 messages per minute to the group. If this limit is exceeded, throttling will be applied for 10 minutes.',
+    'ding_bot_limit' => 'Each bot can send up to 20 messages per minute. Exceeding this limit will trigger a 10-minute rate limit.',
     'empty' => 'You have no new messages',
     'error' => '[:channel] Message push with exception: :reason',
     'get_access_token_failed' => 'Failed to obtain access token!\nWith request parameters: :body',
     'into_maintenance' => 'Automatically enter maintenance mode',
     'new' => '{1} :num new message|[1,*] :num new messages',
-    'new_ticket' => 'New ticket received: :title',
+    'new_ticket' => 'You have received a new ticket: [Title: :title], please click to view details.',
     'next_check_time' => 'Next node blockage detection time: :time',
     'node' => [
         'download' => 'Download',
@@ -35,13 +35,13 @@ return [
     'node_renewal_content' => 'The following nodes are about to expire. Please renew before the expiration to avoid service interruption.',
     'payment_received' => 'Payment received, amount: :amount. View order details',
     'reply_ticket' => 'Ticket replied: :title',
-    'reset_failed' => '[Daily Task]User :uid - :username Data Reset Failed',
+    'reset_failed' => '[Daily Task] Data reset failed for User [ID: :uid, Username: :username]',
     'serverChan_exhausted' => 'Today\'s limit has been exhausted!',
     'serverChan_limit' => 'Frequency too high per minute. Please optimize the notification settings!',
     'sign_failed' => 'Secure signature verification failed',
     'ticket_content' => 'Ticket content:',
-    'traffic_remain' => ':percent% of data used, please pay attention',
-    'traffic_tips' => 'Please note the data reset date and use data rationally, or renew after exhausted',
+    'traffic_remain' => 'You have used :percent% of your data, please manage your remaining data usage wisely',
+    'traffic_tips' => 'Please be aware of the data reset date and plan your usage accordingly, or top up when data is exhausted.',
     'traffic_warning' => 'Data usage warning',
     'verification' => 'Your verification code:',
     'verification_account' => 'Account verification',

+ 8 - 8
resources/lang/fa/notification.php

@@ -9,18 +9,18 @@ return [
     'active_email' => 'لطفاً در عرض 30 دقیقه تأیید را تکمیل کنید',
     'attribute' => 'اعلان',
     'block_report' => 'گزارش مسدود شدن:',
-    'close_ticket' => 'تیکت شماره :id با عنوان :title بسته شده است',
-    'data_anomaly' => 'هشدار کاربر با داده غیرعادی',
-    'data_anomaly_content' => 'کاربر :id: [آپلود: :upload | دانلود: :download | مجموع: :total] در ساعت گذشته',
+    'close_ticket' => 'تیکت [شماره: :id، عنوان: :title] بسته شد',
+    'data_anomaly' => 'اعلان داده غیرعادی برای کاربر',
+    'data_anomaly_content' => 'کاربر [ID: :id] در یک ساعت گذشته از داده‌های زیر استفاده کرده است: [آپلود: :upload، دانلود: :download، مجموع: :total]',
     'details' => 'مشاهده جزئیات',
     'details_btn' => 'لطفاً روی دکمه زیر کلیک کنید تا جزئیات را مشاهده کنید.',
-    'ding_bot_limit' => 'هر ربات می‌تواند حداکثر 20 پیام در دقیقه به گروه ارسال کند. اگر این حد تجاوز شود، محدودیت برای 10 دقیقه اعمال می‌شود.',
+    'ding_bot_limit' => 'هر ربات می‌تواند حداکثر 20 پیام در دقیقه ارسال کند. در صورت تجاوز از این حد، 10 دقیقه محدودیت اعمال می‌شود.',
     'empty' => 'شما در حال حاضر هیچ پیام جدیدی ندارید',
     'error' => '[:channel] ارسال پیام با استثنا: :reason',
     'get_access_token_failed' => 'دریافت توکن دسترسی با شکست مواجه شد!\nبا پارامترهای درخواست: :body',
     'into_maintenance' => 'به‌طور خودکار وارد حالت نگهداری شوید',
     'new' => 'شما :num پیام جدید دارید',
-    'new_ticket' => 'تیکت جدید شما با عنوان :title دریافت شد، لطفاً برای مشاهده مراجعه کنید',
+    'new_ticket' => 'یک تیکت جدید با عنوان :title ایجاد شده است، لطفاً بررسی کنید.',
     'next_check_time' => 'زمان بعدی بررسی انسداد گره: :time',
     'node' => [
         'download' => 'دانلود',
@@ -35,13 +35,13 @@ return [
     'node_renewal_content' => 'گره‌های زیر در حال نزدیک شدن به تاریخ انقضا هستند. لطفاً قبل از انقضا تمدید کنید تا از قطع خدمات جلوگیری شود.',
     'payment_received' => 'پرداخت سفارش شما با مبلغ :amount با موفقیت انجام شد، لطفاً برای مشاهده جزئیات سفارش کلیک کنید',
     'reply_ticket' => 'پاسخ تیکت: :title',
-    'reset_failed' => '[وظیفه روزانه] کاربر :uid - :username بازنشانی داده‌ها ناموفق بود',
+    'reset_failed' => '[وظیفه روزانه] بازنشانی داده‌ها برای کاربر [ID: :uid، نام کاربری: :username] ناموفق بود',
     'serverChan_exhausted' => 'حد مجاز امروز به پایان رسید!',
     'serverChan_limit' => 'فرکانس در هر دقیقه بیش از حد بالا است. لطفاً تنظیمات اعلان را بهینه کنید!',
     'sign_failed' => 'تأیید امضای امنیتی ناموفق بود',
     'ticket_content' => 'محتوای تیکت:',
-    'traffic_remain' => 'شما :percent% از داده خود را استفاده کرده‌اید، لطفاً توجه کنید',
-    'traffic_tips' => 'لطفاً به تاریخ بازنشانی داده توجه کنید و داده‌ها را به صورت منطقی استفاده کنید یا پس از اتمام، مجدداً شارژ کنید',
+    'traffic_remain' => 'میزان مصرف داده شما :percent% است. لطفاً با احتیاط استفاده کنید.',
+    'traffic_tips' => 'لطفاً تاریخ بازنشانی داده را چک کنید و داده‌ها را به‌صورت منطقی مصرف کنید یا در صورت اتمام، مجدداً شارژ کنید.',
     'traffic_warning' => 'هشدار استفاده از داده',
     'verification' => 'کد تأیید شما:',
     'verification_account' => 'تأیید حساب',

+ 8 - 8
resources/lang/ja/notification.php

@@ -9,18 +9,18 @@ return [
     'active_email' => '30分以内に認証を完了してください',
     'attribute' => '通知',
     'block_report' => '詳細なブロックログ:',
-    'close_ticket' => 'チケット番号:id、タイトル:titleがクローズされました',
-    'data_anomaly' => 'データ異常ユーザー通知',
-    'data_anomaly_content' => 'ユーザー:id、最近1時間のデータ(アップロード:upload、ダウンロード:download、合計:total)',
+    'close_ticket' => 'チケット [ID: :id, タイトル: :title] がクローズされました',
+    'data_anomaly' => 'ユーザーのデータ異常通知',
+    'data_anomaly_content' => 'ユーザー [ID: :id] のデータ使用状況: [アップロード: :upload, ダウンロード: :download, 合計: :total](過去1時間)',
     'details' => '詳細を表示',
     'details_btn' => '下のボタンをクリックして、詳細をご覧ください。',
-    'ding_bot_limit' => '各ボットは、グループに対して1分あたり最大20件のメッセージを送信できます。これを超えると、10分間のレート制限が適用されます。',
+    'ding_bot_limit' => '各ボットは1分あたり最大20件のメッセージを送信できます。制限を超えた場合、10分間のレート制限が適用されます。',
     'empty' => '現在、新しいメッセージはありません',
     'error' => '[:channel] メッセージプッシュの例外: :reason',
     'get_access_token_failed' => 'アクセストークンの取得に失敗しました!\nリクエストパラメーター: :body',
     'into_maintenance' => '自動的にメンテナンスモードに入る',
     'new' => '新しいメッセージが:num件あります',
-    'new_ticket' => '新しい返信があります。チケット:titleを確認してください',
+    'new_ticket' => '新しいチケット [タイトル: :title] が作成されました。詳細を確認してください',
     'next_check_time' => '次回ノードブロック検出時間: :time',
     'node' => [
         'download' => 'ダウンロードデータ',
@@ -35,13 +35,13 @@ return [
     'node_renewal_content' => '以下のノードがまもなく期限切れになります。サービス中断を避けるために、期限前に更新してください。',
     'payment_received' => '支払いを受け取りました。金額: :amount。注文の詳細を見る',
     'reply_ticket' => 'チケットの返信::title',
-    'reset_failed' => '[毎日タスク] ユーザー :uid - :username のデータリセットに失敗しました',
+    'reset_failed' => '[日次タスク] ユーザー [ID: :uid, ユーザー名: :username] のデータリセットに失敗しました。',
     'serverChan_exhausted' => '今日の制限が使い切られました!',
     'serverChan_limit' => '分単位の頻度が高すぎます。通知設定を最適化してください!',
     'sign_failed' => 'セキュアサイン確認に失敗しました',
     'ticket_content' => 'チケット内容:',
-    'traffic_remain' => 'データ使用率が:percent%に達しました。ご注意ください',
-    'traffic_tips' => 'データリセット日を確認し、合理的にデータを使用するか、使用後にリチャージしてください',
+    'traffic_remain' => 'データ使用量が :percent% に達しました。残りのデータ使用量に注意してください。',
+    'traffic_tips' => 'データリセット日を確認し、適切にデータを使用してください。データがなくなった場合はリチャージしてください。',
     'traffic_warning' => 'データ使用警告',
     'verification' => 'あなたの認証コードは:',
     'verification_account' => 'アカウント認証通知',

+ 8 - 8
resources/lang/ko/notification.php

@@ -9,18 +9,18 @@ return [
     'active_email' => '30분 내에 인증을 완료해 주세요',
     'attribute' => '알림',
     'block_report' => '상세 차단 로그:',
-    'close_ticket' => '티켓 번호:id, 제목:title이(가) 닫혔습니다',
-    'data_anomaly' => '데이터 이상 사용자 알림',
-    'data_anomaly_content' => '사용자:id, 최근 1시간 데이터 사용량 (업로드: :upload, 다운로드: :download, 총계: :total)',
+    'close_ticket' => '티켓 [ID: :id, 제목: :title] 이(가) 닫혔습니다',
+    'data_anomaly' => '사용자의 데이터 이상 알림',
+    'data_anomaly_content' => '사용자 [ID: :id] 의 데이터 사용 내역: [업로드: :upload, 다운로드: :download, 총계: :total] (지난 1시간 동안)',
     'details' => '세부정보 보기',
     'details_btn' => '아래 버튼을 클릭하여 세부정보를 확인하십시오.',
-    'ding_bot_limit' => '각 봇은 그룹에 1분당 최대 20개의 메시지를 보낼 수 있습니다. 이 제한을 초과하면 10분 동안 속도 제한이 적용됩니다.',
+    'ding_bot_limit' => '각 봇은 1분당 최대 20개의 메시지를 보낼 수 있습니다. 이 제한을 초과하면 10분 동안 속도 제한이 적용됩니다.',
     'empty' => '현재 새 메시지가 없습니다',
     'error' => '[:channel] 메시지 푸시 예외: :reason',
     'get_access_token_failed' => 'access_token을 가져오는 데 실패했습니다!\n요청 파라미터: :body',
     'into_maintenance' => '자동으로 유지 관리 모드로 전환',
     'new' => '새 메시지가 :num개 있습니다',
-    'new_ticket' => '새 티켓:title에 대한 답변이 있습니다. 확인해 주세요',
+    'new_ticket' => '새 티켓이 생성되었습니다: :title. 자세한 내용을 확인해 주세요.',
     'next_check_time' => '다음 노드 차단 탐지 시간: :time',
     'node' => [
         'download' => '다운로드 트래픽',
@@ -35,13 +35,13 @@ return [
     'node_renewal_content' => '다음 노드가 곧 만료됩니다. 서비스 중단을 방지하기 위해 만료 전 갱신해 주시기 바랍니다.',
     'payment_received' => '주문 결제가 완료되었습니다. 금액: :amount, 주문 세부사항을 확인하세요',
     'reply_ticket' => '티켓 답변: :title',
-    'reset_failed' => '[일일 작업] 사용자 :uid - :username 데이터 재설정 실패',
+    'reset_failed' => '[일일 작업] 사용자 [ID: :uid, 사용자 이름: :username] 의 데이터 재설정 실패',
     'serverChan_exhausted' => '오늘의 한도가 소진되었습니다!',
     'serverChan_limit' => '분당 빈도가 너무 높습니다. 알림 설정을 최적화하세요!',
     'sign_failed' => '보안 서명 검증 실패',
     'ticket_content' => '티켓 내용:',
-    'traffic_remain' => '귀하의 데이터 사용량이 :percent%입니다. 합리적으로 사용해 주세요',
-    'traffic_tips' => '데이터 초기화 날짜에 유의하고, 합리적으로 사용하거나 소진 후 충전해 주세요',
+    'traffic_remain' => '데이터 사용량이 :percent% 에 도달했습니다. 남은 데이터를 유의해 주세요.',
+    'traffic_tips' => '데이터 초기화 날짜를 확인하고, 합리적으로 데이터를 사용해 주세요. 데이터 소진 시 충전해 주세요.',
     'traffic_warning' => '데이터 사용량 경고',
     'verification' => '귀하의 인증 코드는:',
     'verification_account' => '계정 인증 알림',

+ 8 - 8
resources/lang/vi/notification.php

@@ -9,18 +9,18 @@ return [
     'active_email' => 'Vui lòng hoàn thành xác minh trong vòng 30 phút',
     'attribute' => 'Thông báo',
     'block_report' => 'Báo cáo chặn:',
-    'close_ticket' => 'Yêu cầu :id: :title đã đóng',
-    'data_anomaly' => 'Cảnh báo người dùng dữ liệu bất thường',
-    'data_anomaly_content' => 'Người dùng :id: [Tải lên: :upload | Tải xuống: :download | Tổng cộng: :total] trong giờ qua',
+    'close_ticket' => 'Yêu cầu [ID: :id, tiêu đề: :title] đã được đóng',
+    'data_anomaly' => 'Cảnh báo dữ liệu bất thường cho người dùng',
+    'data_anomaly_content' => 'Người dùng [ID: :id] đã sử dụng dữ liệu sau trong một giờ qua: [Tải lên: :upload, Tải xuống: :download, Tổng cộng: :total]',
     'details' => 'Xem chi tiết',
     'details_btn' => 'Vui lòng nhấp vào nút dưới đây để xem chi tiết.',
-    'ding_bot_limit' => 'Mỗi bot có thể gửi tối đa 20 tin nhắn mỗi phút vào nhóm. Nếu vượt quá giới hạn này, sẽ áp dụng hạn chế tốc độ trong 10 phút.',
+    'ding_bot_limit' => 'Mỗi bot có thể gửi tối đa 20 tin nhắn mỗi phút. Nếu vượt quá, sẽ có hạn chế trong 10 phút.',
     'empty' => 'Bạn không có tin nhắn mới',
     'error' => '[:channel] Lỗi đẩy tin nhắn: :reason',
     'get_access_token_failed' => 'Lấy access_token thất bại!\nVới các tham số yêu cầu: :body',
     'into_maintenance' => 'Tự động chuyển vào chế độ bảo trì',
     'new' => '{1} :num tin nhắn mới|[1,*] :num tin nhắn mới',
-    'new_ticket' => 'Yêu cầu mới nhận được: :title',
+    'new_ticket' => 'Yêu cầu mới với tiêu đề :title đã được tạo. Vui lòng kiểm tra.',
     'next_check_time' => 'Thời gian phát hiện chặn nút tiếp theo: :time',
     'node' => [
         'download' => 'Tải xuống',
@@ -35,13 +35,13 @@ return [
     'node_renewal_content' => 'Các nút sau đây sắp hết hạn. Vui lòng gia hạn trước khi hết hạn để tránh gián đoạn dịch vụ.',
     'payment_received' => 'Thanh toán đã nhận, số tiền: :amount. Xem chi tiết đơn hàng',
     'reply_ticket' => 'Yêu cầu đã trả lời: :title',
-    'reset_failed' => '[Nhiệm vụ hàng ngày] Người dùng :uid - :username Đặt lại dữ liệu không thành công',
+    'reset_failed' => '[Nhiệm vụ hàng ngày] Đặt lại dữ liệu cho người dùng [ID: :uid, tên người dùng: :username] không thành công',
     'serverChan_exhausted' => 'Hạn mức hôm nay đã hết!',
     'serverChan_limit' => 'Tần suất mỗi phút quá cao. Vui lòng tối ưu hóa cài đặt thông báo!',
     'sign_failed' => 'Xác thực chữ ký bảo mật thất bại',
     'ticket_content' => 'Nội dung yêu cầu:',
-    'traffic_remain' => 'Đã sử dụng :percent% dữ liệu, vui lòng chú ý',
-    'traffic_tips' => 'Vui lòng lưu ý ngày đặt lại dữ liệu và sử dụng dữ liệu hợp lý, hoặc gia hạn sau khi hết',
+    'traffic_remain' => 'Bạn đã sử dụng :percent% dữ liệu. Vui lòng sử dụng cẩn thận.',
+    'traffic_tips' => 'Xin lưu ý ngày đặt lại dữ liệu và sử dụng dữ liệu hợp lý. Hoặc gia hạn nếu cần sau khi hết.',
     'traffic_warning' => 'Cảnh báo sử dụng dữ liệu',
     'verification' => 'Mã xác minh của bạn:',
     'verification_account' => 'Xác minh tài khoản',

+ 8 - 8
resources/lang/zh_CN/notification.php

@@ -9,18 +9,18 @@ return [
     'active_email' => '请在30分钟内完成验证',
     'attribute' => '通知',
     'block_report' => '详细阻断日志:',
-    'close_ticket' => '工单编号:id,标题:title已被关闭',
-    'data_anomaly' => '流量异常用户提醒',
-    'data_anomaly_content' => '用户:id,最近1小时流量(上传:upload,下载:download,总计:total)',
+    'close_ticket' => '工单 [ID: :id, 标题: :title] 已关闭',
+    'data_anomaly' => '用户流量异常提醒',
+    'data_anomaly_content' => '用户 [ID: :id],最近1小时的流量情况(上传: :upload, 下载: :download, 总计: :total)',
     'details' => '查看详情',
     'details_btn' => '请点击下方按钮【查看详情】',
-    'ding_bot_limit' => '每个机器人每分钟最多发送20条消息到群里,如果超过20条,会限流10分钟。',
+    'ding_bot_limit' => '每个机器人每分钟最多发送 20 条消息,超出将限流 10 分钟。',
     'empty' => '您当前没有新消息',
     'error' => '[:channel] 消息推送异常: :reason',
     'get_access_token_failed' => '获取access_token失败!\n携带访问参数: :body',
     'into_maintenance' => '自动进入维护状态',
     'new' => '您有:num条新消息',
-    'new_ticket' => '您的工单:title收到新的回复,请前往查看',
+    'new_ticket' => '您收到了一个新工单 [标题: :title],请点击查看详细内容。',
     'next_check_time' => '下次节点阻断检测时间::time',
     'node' => [
         'download' => '下载流量',
@@ -35,13 +35,13 @@ return [
     'node_renewal_content' => '以下节点即将到期,请在到期前续费,以避免服务中断。',
     'payment_received' => '您的订单支付成功,金额为:amount,请点击查看订单详情',
     'reply_ticket' => '工单回复::title',
-    'reset_failed' => '[每日任务]用户:uid - :username 流量重置失败',
+    'reset_failed' => '[每日任务] 用户 [ID: :uid, 用户名: :username] 流量重置失败',
     'serverChan_exhausted' => '今日限额已耗尽!',
     'serverChan_limit' => '分钟频率过高,请优化通知场景!',
     'sign_failed' => '安全签名验证失败',
     'ticket_content' => '工单内容:',
-    'traffic_remain' => '您的流量已使用:percent%,请合理安排使用',
-    'traffic_tips' => '请注意流量重置日,合理使用流量或在耗尽后充值',
+    'traffic_remain' => '您已使用:percent%的流量,请合理安排剩余流量',
+    'traffic_tips' => '请留意流量重置日,合理规划使用或在流量耗尽后充值。',
     'traffic_warning' => '流量使用提醒',
     'verification' => '您的验证码为:',
     'verification_account' => '账号验证通知',

+ 11 - 0
resources/views/_layout.blade.php

@@ -66,6 +66,17 @@
     <script src="/assets/global/js/Plugin/asscrollable.js"></script>
     <script src="/assets/global/js/Plugin/slidepanel.js"></script>
     <script>
+        // Create and append link element to load the font CSS asynchronously
+        const link = document.createElement("link");
+        link.rel = 'stylesheet';
+        link.href = 'https://fonts.loli.net/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap';
+        document.head.appendChild(link);
+
+        // Apply font to body after font has loaded
+        link.onload = function() {
+            document.body.style.fontFamily = 'Roboto, system-ui, sans-serif';
+        };
+
         (function(document, window, $) {
             "use strict";
             const Site = window.Site;

+ 142 - 133
resources/views/admin/report/nodeDataAnalysis.blade.php

@@ -8,14 +8,6 @@
         <div class="card card-shadow">
             <div class="card-block p-30">
                 <form class="form-row">
-                    <div class="form-group col-xxl-2 col-lg-3 col-md-3 col-sm-4">
-                        <select class="form-control show-tick" id="hour_date" name="hour_date" data-plugin="selectpicker" data-style="btn-outline btn-primary"
-                                title="{{ trans('admin.report.select_hourly_date') }}" onchange="cleanSubmit(this.form);this.form.submit()">
-                            @foreach ($hour_dates as $date)
-                                <option value="{{ $date }}">{{ $date }}</option>
-                            @endforeach
-                        </select>
-                    </div>
                     <div class="form-group col-xxl-2 col-lg-3 col-md-3 col-sm-4">
                         <select class="form-control show-tick" id="nodes" name="nodes[]" data-plugin="selectpicker" data-style="btn-outline btn-primary"
                                 title="{{ trans('admin.logs.user_traffic.choose_node') }}" multiple>
@@ -24,18 +16,6 @@
                             @endforeach
                         </select>
                     </div>
-                    <div class="form-group col-lg-6 col-sm-12">
-                        <div class="input-group input-daterange" data-plugin="datepicker">
-                            <div class="input-group-prepend">
-                                <span class="input-group-text"><i class="icon wb-calendar" aria-hidden="true"></i></span>
-                            </div>
-                            <input class="form-control" name="start" type="text" value="{{ Request::query('start') }}" autocomplete="off" />
-                            <div class="input-group-prepend">
-                                <span class="input-group-text">{{ trans('common.to') }}</span>
-                            </div>
-                            <input class="form-control" name="end" type="text" value="{{ Request::query('end') }}" autocomplete="off" />
-                        </div>
-                    </div>
                     <div class="form-group col-xxl-1 col-lg-3 col-md-3 col-4 btn-group">
                         <button class="btn btn-primary" type="submit">{{ trans('common.search') }}</button>
                         <button class="btn btn-danger" type="button" onclick="resetSearchForm()">{{ trans('common.reset') }}</button>
@@ -47,7 +27,23 @@
             <div class="row mx-0">
                 <div class="col-md-12 col-xxl-7 card card-shadow">
                     <div class="card-block p-md-30">
-                        <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.hourly_traffic') }}</div>
+                        <div class="row pb-20">
+                            <div class="col-md-4 col-sm-6">
+                                <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.hourly_traffic') }}</div>
+                            </div>
+                            <div class="col-md-8 col-sm-6">
+                                <form class="form-row float-right">
+                                    <div class="form-group">
+                                        <select class="form-control show-tick" id="hour_date" name="hour_date" data-plugin="selectpicker"
+                                                data-style="btn-outline btn-primary" title="{{ trans('admin.report.select_hourly_date') }}">
+                                            @foreach ($hour_dates as $date)
+                                                <option value="{{ $date }}">{{ $date }}</option>
+                                            @endforeach
+                                        </select>
+                                    </div>
+                                </form>
+                            </div>
+                        </div>
                         <canvas id="hourlyBar"></canvas>
                     </div>
                 </div>
@@ -59,9 +55,34 @@
                         </div>
                     </div>
                 </div>
-                <div class="col-12 offset-xxl-2 col-xxl-8 card card-shadow">
+                <div class="col-12 offset-xxl-1 col-xxl-10 card card-shadow">
                     <div class="card-block p-md-30">
-                        <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.daily_traffic') }}</div>
+                        <div class="row pb-20">
+                            <div class="col-md-4 col-sm-6">
+                                <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.daily_traffic') }}</div>
+                            </div>
+                            <div class="col-md-8 col-sm-6">
+                                <form class="form-row float-right" onsubmit="handleFormSubmit(event, this);">
+                                    <div class="form-group">
+                                        <div class="input-group input-daterange" data-plugin="datepicker">
+                                            <div class="input-group-prepend">
+                                                <span class="input-group-text"><i class="icon wb-calendar" aria-hidden="true"></i></span>
+                                            </div>
+                                            <input class="form-control" name="start" type="text"
+                                                   value="{{ Request::query('start', now()->startOfMonth()->format('Y-m-d')) }}" autocomplete="off" />
+                                            <div class="input-group-prepend">
+                                                <span class="input-group-text">{{ trans('common.to') }}</span>
+                                            </div>
+                                            <input class="form-control" name="end" type="text" value="{{ Request::query('end', now()->format('Y-m-d')) }}"
+                                                   autocomplete="off" />
+                                            <div class="input-group-addon">
+                                                <button class="btn btn-primary" type="submit">{{ trans('common.search') }}</button>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </form>
+                            </div>
+                        </div>
                         <canvas id="dailyBar"></canvas>
                     </div>
                 </div>
@@ -78,33 +99,19 @@
     <script src="/assets/global/js/Plugin/bootstrap-datepicker.js"></script>
     <script type="text/javascript">
         const nodeData = @json($data);
-        const nodeColorMap = generateNodeColorMap(nodeData.nodes);
-
-        function resetSearchForm() {
-            window.location.href = window.location.href.split('?')[0];
-        }
-
-        $('.input-daterange').datepicker({
-            format: 'yyyy-mm-dd',
-            startDate: nodeData.start_date,
-            endDate: new Date(),
-        });
-
-        $('form').on('submit', function() {
-            cleanSubmit(this);
-        });
 
-        function cleanSubmit(form) {
-            $(form).find('input:not([type="submit"]), select').filter(function() {
-                return this.value === "";
-            }).prop('disabled', true);
+        const getRandomColor = (name) => {
+            const hash = name.split('').reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0);
+            const hue = (hash % 360 + Math.random() * 50) % 360;
+            const saturation = 50 + (hash % 30);
+            const lightness = 40 + (hash % 20);
+            return `hsla(${hue}, ${saturation}%, ${lightness}%, 0.55)`;
+        };
 
-            setTimeout(function() {
-                $(form).find(':disabled').prop('disabled', false);
-            }, 0);
-        }
+        const generateNodeColorMap = (nodeNames) =>
+            Object.fromEntries(Object.entries(nodeNames).map(([id, name]) => [id, getRandomColor(name)]));
 
-        function optimizeDatasets(datasets) {
+        const optimizeDatasets = (datasets) => {
             const dataByDate = datasets.reduce((acc, dataset) => {
                 dataset.data.forEach(item => {
                     acc[item.time] = acc[item.time] || [];
@@ -117,18 +124,11 @@
             }, {});
 
             const allNodeIds = datasets.map(d => d.label);
-            const optimizedData = Object.entries(dataByDate).map(([date, dayData]) => {
-                const total = dayData.reduce((sum, item) => sum + item.total, 0);
-                const filledData = allNodeIds.map(id => {
-                    const nodeData = dayData.find(item => item.id === id);
-                    return nodeData ? nodeData.total : 0;
-                });
-                return {
-                    time: date,
-                    data: filledData,
-                    total
-                };
-            });
+            const optimizedData = Object.entries(dataByDate).map(([date, dayData]) => ({
+                time: date,
+                data: allNodeIds.map(id => dayData.find(item => item.id === id)?.total || 0),
+                total: dayData.reduce((sum, item) => sum + item.total, 0)
+            }));
 
             return datasets.map((dataset, index) => ({
                 ...dataset,
@@ -137,14 +137,31 @@
                     total: day.data[index]
                 }))
             }));
-        }
+        };
+
+        const generateDatasets = (flows, nodeColorMap) =>
+            Object.entries(flows.reduce((acc, flow) => {
+                acc[flow.id] = acc[flow.id] || [];
+                acc[flow.id].push({
+                    time: flow.time,
+                    total: parseFloat(flow.total),
+                    name: flow.name
+                });
+                return acc;
+            }, {})).map(([nodeId, data]) => ({
+                label: data[0].name,
+                backgroundColor: nodeColorMap[nodeId],
+                borderColor: nodeColorMap[nodeId],
+                data,
+                fill: true,
+            }));
 
-        function createBarChart(elementId, labels, datasets, labelTail, unit = 'MiB') {
+        const createBarChart = (elementId, labels, datasets, labelTail, unit = 'GiB') => {
             const optimizedDatasets = optimizeDatasets(datasets);
             new Chart(document.getElementById(elementId), {
                 type: 'bar',
                 data: {
-                    labels: optimizedDatasets[0]?.data.map(d => d.time),
+                    labels: labels || optimizedDatasets[0]?.data.map(d => d.time),
                     datasets: optimizedDatasets
                 },
                 plugins: [ChartDataLabels],
@@ -173,14 +190,26 @@
                                 }
                             },
                         },
-                        tooltip: label_callbacks(labelTail, unit),
+                        tooltip: {
+                            mode: 'index',
+                            intersect: false,
+                            callbacks: {
+                                title: context => `${context[0].label} ${labelTail}`,
+                                label: context => {
+                                    const dataset = context.dataset;
+                                    const value = dataset.data[context.dataIndex]?.total || context.parsed.y;
+                                    return `${dataset.label || ''}: ${value.toFixed(2)} ${unit}`;
+                                }
+                            }
+                        },
                         datalabels: {
                             display: true,
                             align: 'end',
                             anchor: 'end',
                             formatter: (value, context) => {
                                 if (context.datasetIndex === context.chart.data.datasets.length - 1) {
-                                    let total = context.chart.data.datasets.reduce((sum, dataset) => sum + dataset.data[context.dataIndex].total, 0);
+                                    let total = context.chart.data.datasets.reduce((sum, dataset) => sum + dataset.data[context.dataIndex].total,
+                                        0);
                                     return total.toFixed(2) + unit;
                                 }
                                 return null;
@@ -189,59 +218,9 @@
                     },
                 },
             });
-        }
-
-        function label_callbacks(tail, unit) {
-            return {
-                mode: 'index',
-                intersect: false,
-                callbacks: {
-                    title: context => `${context[0].label} ${tail}`,
-                    label: context => {
-                        const dataset = context.dataset;
-                        const value = dataset.data[context.dataIndex]?.total || context.parsed.y;
-                        return `${dataset.label || ''}: ${value.toFixed(2)} ${unit}`;
-                    }
-                }
-            };
-        }
-
-        function generateNodeColorMap(nodeNames) {
-            return Object.fromEntries(Object.entries(nodeNames).map(([id, name]) => [id, getRandomColor(name)]));
-        }
-
-        function getRandomColor(name) {
-            let hash = 0;
-            for (let i = 0; i < name.length; i++) {
-                hash = name.charCodeAt(i) + ((hash << 5) - hash);
-            }
-            const hue = (hash % 360 + Math.random() * 50) % 360;
-            const saturation = 50 + (hash % 30);
-            const lightness = 40 + (hash % 20);
-            return `hsla(${hue}, ${saturation}%, ${lightness}%, 0.55)`;
-        }
-
-        function generateDatasets(flows) {
-            const dataByNode = flows.reduce((acc, flow) => {
-                acc[flow.id] = acc[flow.id] || [];
-                acc[flow.id].push({
-                    time: flow.time,
-                    total: parseFloat(flow.total),
-                    name: flow.name
-                });
-                return acc;
-            }, {});
+        };
 
-            return Object.entries(dataByNode).map(([nodeId, data]) => ({
-                label: data[0].name,
-                backgroundColor: nodeColorMap[nodeId],
-                borderColor: nodeColorMap[nodeId],
-                data,
-                fill: true,
-            }));
-        }
-
-        function createDoughnutChart(elementId, labels, data, colors, date) {
+        const createDoughnutChart = (elementId, labels, data, colors, date) => {
             Chart.register({
                 id: 'totalLabel',
                 beforeDraw(chart) {
@@ -327,7 +306,7 @@
             });
         }
 
-        function generatePieData(flows) {
+        const generatePieData = (flows, nodeColorMap) => {
             return {
                 labels: flows.map(flow => flow.name),
                 data: flows.map(flow => flow.total),
@@ -335,25 +314,55 @@
             };
         }
 
-        function initCharts() {
-            createBarChart('hourlyBar', nodeData.hours, generateDatasets(nodeData.hourlyFlows), @json(trans_choice('common.hour', 2)), ' GiB');
-            createBarChart('dailyBar', '', generateDatasets(nodeData.dailyFlows), '', ' GiB');
+        const initCharts = () => {
+            if (nodeData) {
+                const nodeColorMap = generateNodeColorMap(nodeData.nodes);
+                createBarChart('hourlyBar', nodeData.hours.map(String), generateDatasets(nodeData.hourlyFlows, nodeColorMap), @json(trans_choice('common.hour', 2)),
+                    'GiB');
+                createBarChart('dailyBar', '', generateDatasets(nodeData.dailyFlows, nodeColorMap), '', 'GiB');
 
-            const lastDate = nodeData.dailyFlows[nodeData.dailyFlows.length - 1].time;
-            const lastDayData = nodeData.dailyFlows.filter(flow => flow.time === lastDate);
-            const {
-                labels,
-                data,
-                colors
-            } = generatePieData(lastDayData);
-            createDoughnutChart('dailyPie', labels, data, colors, lastDate);
-        }
+                const lastDate = nodeData.dailyFlows[nodeData.dailyFlows.length - 1].time;
+                const lastDayData = nodeData.dailyFlows.filter(flow => flow.time === lastDate);
+                const {
+                    labels,
+                    data,
+                    colors
+                } = generatePieData(lastDayData, nodeColorMap);
+                createDoughnutChart('dailyPie', labels, data, colors, lastDate);
+            }
+        };
 
-        $(document).ready(function() {
-            $('#nodes').selectpicker('val', @json(Request::query('nodes')));
-            $('#hour_date').selectpicker('val', @json(Request::query('hour_date')));
-            $('.input-daterange').datepicker('update', @json(Request::query('start')), @json(Request::query('end')));
+        const handleFormSubmit = (event, form) => {
+            event.preventDefault();
+            let urlParams = new URLSearchParams(window.location.search);
+            let formData = new FormData(form);
+
+            for (let [key, value] of formData.entries()) {
+                value ? urlParams.set(key, value) : urlParams.delete(key);
+            }
+
+            window.location.href = `${window.location.pathname}?${urlParams.toString()}`;
+        };
+
+        const resetSearchForm = () => {
+            window.location.href = window.location.href.split('?')[0];
+        };
+
+        document.addEventListener('DOMContentLoaded', () => {
             initCharts();
+            $('#nodes').selectpicker('val', @json(Request::query('nodes')));
+
+            const hourDateSelect = document.getElementById('hour_date');
+            if (hourDateSelect) {
+                hourDateSelect.addEventListener('change', (event) => handleFormSubmit(event, event.target.form));
+                $(hourDateSelect).selectpicker('val', new URLSearchParams(window.location.search).get('hour_date') || @json(now()->format('Y-m-d')));
+            }
+
+            $('.input-daterange').datepicker({
+                format: 'yyyy-mm-dd',
+                startDate: nodeData.start_date,
+                endDate: new Date(),
+            });
         });
     </script>
 @endsection

+ 190 - 151
resources/views/admin/report/userDataAnalysis.blade.php

@@ -1,9 +1,13 @@
 @extends('admin.layouts')
+@section('css')
+    <link href="/assets/global/vendor/bootstrap-select/bootstrap-select.min.css" rel="stylesheet">
+    <link href="/assets/global/vendor/bootstrap-datepicker/bootstrap-datepicker.min.css" rel="stylesheet">
+@endsection
 @section('content')
     <div class="page-content container">
         <div class="card card-shadow">
             <div class="card-block p-30">
-                <form class="form-row">
+                <form class="form-row" onsubmit="handleFormSubmit(event, this);">
                     <div class="form-group col-xxl-1 col-lg-1 col-md-1 col-sm-4">
                         <input class="form-control" name="uid" type="number" value="{{ Request::query('uid') }}" placeholder="{{ trans('model.user.id') }}" />
                     </div>
@@ -18,13 +22,25 @@
                 </form>
             </div>
         </div>
-        @isset($data)
+        @if (count($data) > 2)
             <div class="card card-shadow">
                 <div class="card-block p-30">
                     <div class="row pb-20">
-                        <div class="col-md-8 col-sm-6">
+                        <div class="col-md-4 col-sm-6">
                             <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.hourly_traffic') }}</div>
                         </div>
+                        <div class="col-md-8 col-sm-6">
+                            <form class="form-row float-right">
+                                <div class="form-group">
+                                    <select class="form-control show-tick" id="hour_date" name="hour_date" data-plugin="selectpicker"
+                                            data-style="btn-outline btn-primary" title="{{ trans('admin.report.select_hourly_date') }}">
+                                        @foreach ($data['hour_dates'] as $date)
+                                            <option value="{{ $date }}">{{ $date }}</option>
+                                        @endforeach
+                                    </select>
+                                </div>
+                            </form>
+                        </div>
                     </div>
                     <canvas id="hourlyBar"></canvas>
                 </div>
@@ -32,185 +48,208 @@
             <div class="card card-shadow">
                 <div class="card-block p-30">
                     <div class="row pb-20">
-                        <div class="col-md-8 col-sm-6">
+                        <div class="col-md-4 col-sm-6">
                             <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.daily_traffic') }}</div>
                         </div>
+                        <div class="col-md-8 col-sm-6">
+                            <form class="form-row float-right" onsubmit="handleFormSubmit(event, this);">
+                                <div class="form-group">
+                                    <div class="input-group input-daterange" data-plugin="datepicker">
+                                        <div class="input-group-prepend">
+                                            <span class="input-group-text"><i class="icon wb-calendar" aria-hidden="true"></i></span>
+                                        </div>
+                                        <input class="form-control" name="start" type="text"
+                                               value="{{ Request::query('start', now()->startOfMonth()->format('Y-m-d')) }}" autocomplete="off" />
+                                        <div class="input-group-prepend">
+                                            <span class="input-group-text">{{ trans('common.to') }}</span>
+                                        </div>
+                                        <input class="form-control" name="end" type="text" value="{{ Request::query('end', now()->format('Y-m-d')) }}"
+                                               autocomplete="off" />
+                                        <div class="input-group-addon">
+                                            <button class="btn btn-primary" type="submit">{{ trans('common.search') }}</button>
+                                        </div>
+                                    </div>
+                                </div>
+                            </form>
+                        </div>
                     </div>
                     <canvas id="dailyBar"></canvas>
                 </div>
             </div>
-        @endisset
+        @endif
     </div>
 @endsection
 @section('javascript')
-    @isset($data)
-        <script src="/assets/global/vendor/chart-js/chart.min.js"></script>
-        <script src="/assets/global/vendor/chart-js/chartjs-plugin-datalabels.min.js"></script>
-        <script type="text/javascript">
-            const userData = @json($data);
-            const nodeColorMap = generateNodeColorMap(userData.nodes); // 获取所有节点名称并生成颜色映射
-
-            function resetSearchForm() {
-                window.location.href = window.location.href.split('?')[0];
-            }
+    <script src="/assets/global/vendor/chart-js/chart.min.js"></script>
+    <script src="/assets/global/vendor/chart-js/chartjs-plugin-datalabels.min.js"></script>
+    <script src="/assets/global/vendor/bootstrap-select/bootstrap-select.min.js"></script>
+    <script src="/assets/global/js/Plugin/bootstrap-select.js"></script>
+    <script src="/assets/global/vendor/bootstrap-datepicker/bootstrap-datepicker.min.js"></script>
+    <script src="/assets/global/js/Plugin/bootstrap-datepicker.js"></script>
+    <script type="text/javascript">
+        const userData = @json($data);
 
-            function handleFormSubmit() {
-                $('form').on('submit', function() {
-                    $(this).find('input, select').each(function() {
-                        if (!$(this).val()) {
-                            $(this).remove();
-                        }
-                    });
-                });
-            }
+        const getRandomColor = (name) => {
+            const hash = name.split('').reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0);
+            const hue = (hash % 360 + Math.random() * 50) % 360;
+            const saturation = 50 + (hash % 30);
+            const lightness = 40 + (hash % 20);
+            return `hsla(${hue}, ${saturation}%, ${lightness}%, 0.55)`;
+        };
 
-            function optimizeDatasets(datasets) {
-                const dataByDate = datasets.reduce((acc, dataset) => {
-                    dataset.data.forEach(item => {
-                        acc[item.time] = acc[item.time] || [];
-                        acc[item.time].push({
-                            id: dataset.label,
-                            total: parseFloat(item.total)
-                        });
-                    });
-                    return acc;
-                }, {});
-
-                const allNodeIds = datasets.map(d => d.label);
-                const optimizedData = Object.entries(dataByDate).map(([date, dayData]) => {
-                    const total = dayData.reduce((sum, item) => sum + item.total, 0);
-                    const filledData = allNodeIds.map(id => {
-                        const nodeData = dayData.find(item => item.id === id);
-                        return nodeData ? nodeData.total : 0;
+        const generateNodeColorMap = (nodeNames) =>
+            Object.fromEntries(Object.entries(nodeNames).map(([id, name]) => [id, getRandomColor(name)]));
+
+        const optimizeDatasets = (datasets) => {
+            const dataByDate = datasets.reduce((acc, dataset) => {
+                dataset.data.forEach(item => {
+                    acc[item.time] = acc[item.time] || [];
+                    acc[item.time].push({
+                        id: dataset.label,
+                        total: parseFloat(item.total)
                     });
-                    return {
-                        time: date,
-                        data: filledData,
-                        total
-                    };
                 });
+                return acc;
+            }, {});
 
-                return datasets.map((dataset, index) => ({
-                    ...dataset,
-                    data: optimizedData.map(day => ({
-                        time: day.time,
-                        total: day.data[index]
-                    }))
-                }));
-            }
+            const allNodeIds = datasets.map(d => d.label);
+            const optimizedData = Object.entries(dataByDate).map(([date, dayData]) => ({
+                time: date,
+                data: allNodeIds.map(id => dayData.find(item => item.id === id)?.total || 0),
+                total: dayData.reduce((sum, item) => sum + item.total, 0)
+            }));
+
+            return datasets.map((dataset, index) => ({
+                ...dataset,
+                data: optimizedData.map(day => ({
+                    time: day.time,
+                    total: day.data[index]
+                }))
+            }));
+        };
 
-            function createBarChart(elementId, labels, datasets, labelTail, unit = 'MiB') {
-                const optimizedDatasets = optimizeDatasets(datasets);
-                new Chart(document.getElementById(elementId), {
-                    type: 'bar',
-                    data: {
-                        labels: optimizedDatasets[0]?.data.map(d => d.time),
-                        datasets: optimizedDatasets
+        const generateDatasets = (flows, nodeColorMap) =>
+            Object.entries(flows.reduce((acc, flow) => {
+                acc[flow.id] = acc[flow.id] || [];
+                acc[flow.id].push({
+                    time: flow.time,
+                    total: parseFloat(flow.total),
+                    name: flow.name
+                });
+                return acc;
+            }, {})).map(([nodeId, data]) => ({
+                label: data[0].name,
+                backgroundColor: nodeColorMap[nodeId],
+                borderColor: nodeColorMap[nodeId],
+                data,
+                fill: true,
+            }));
+
+        const createBarChart = (elementId, labels, datasets, labelTail, unit = 'MiB') => {
+            const optimizedDatasets = optimizeDatasets(datasets);
+            new Chart(document.getElementById(elementId), {
+                type: 'bar',
+                data: {
+                    labels: labels || optimizedDatasets[0]?.data.map(d => d.time),
+                    datasets: optimizedDatasets
+                },
+                plugins: [ChartDataLabels],
+                options: {
+                    parsing: {
+                        xAxisKey: 'time',
+                        yAxisKey: 'total'
                     },
-                    plugins: [ChartDataLabels],
-                    options: {
-                        parsing: {
-                            xAxisKey: 'time',
-                            yAxisKey: 'total'
+                    scales: {
+                        x: {
+                            stacked: true
                         },
-                        scales: {
-                            x: {
-                                stacked: true
+                        y: {
+                            stacked: true
+                        }
+                    },
+                    responsive: true,
+                    plugins: {
+                        legend: {
+                            labels: {
+                                padding: 10,
+                                usePointStyle: true,
+                                pointStyle: 'circle',
+                                font: {
+                                    size: 14
+                                }
                             },
-                            y: {
-                                stacked: true
+                        },
+                        tooltip: {
+                            mode: 'index',
+                            intersect: false,
+                            callbacks: {
+                                title: context => `${context[0].label} ${labelTail}`,
+                                label: context => {
+                                    const dataset = context.dataset;
+                                    const value = dataset.data[context.dataIndex]?.total || context.parsed.y;
+                                    return `${dataset.label || ''}: ${value.toFixed(2)} ${unit}`;
+                                }
                             }
                         },
-                        responsive: true,
-                        plugins: {
-                            legend: {
-                                labels: {
-                                    padding: 10,
-                                    usePointStyle: true,
-                                    pointStyle: 'circle',
-                                    font: {
-                                        size: 14
-                                    }
-                                },
-                            },
-                            tooltip: label_callbacks(labelTail, unit),
-                            datalabels: {
-                                display: true,
-                                align: 'end',
-                                anchor: 'end',
-                                formatter: (value, context) => {
-                                    if (context.datasetIndex === context.chart.data.datasets.length - 1) {
-                                        let total = context.chart.data.datasets.reduce((sum, dataset) => sum + dataset.data[context.dataIndex].total, 0);
-                                        return total.toFixed(2) + unit;
-                                    }
-                                    return null;
-                                },
+                        datalabels: {
+                            display: true,
+                            align: 'end',
+                            anchor: 'end',
+                            formatter: (value, context) => {
+                                if (context.datasetIndex === context.chart.data.datasets.length - 1) {
+                                    let total = context.chart.data.datasets.reduce((sum, dataset) => sum + dataset.data[context.dataIndex].total,
+                                        0);
+                                    return total.toFixed(2) + unit;
+                                }
+                                return null;
                             },
                         },
                     },
-                });
-            }
+                },
+            });
+        };
 
-            function label_callbacks(tail, unit) {
-                return {
-                    mode: 'index',
-                    intersect: false,
-                    callbacks: {
-                        title: context => `${context[0].label} ${tail}`,
-                        label: context => {
-                            const dataset = context.dataset;
-                            const value = dataset.data[context.dataIndex]?.total || context.parsed.y;
-                            return `${dataset.label || ''}: ${value.toFixed(2)} ${unit}`;
-                        }
-                    }
-                };
-            }
+        const handleFormSubmit = (event, form) => {
+            event.preventDefault();
+            let urlParams = new URLSearchParams(window.location.search);
+            let formData = new FormData(form);
 
-            function generateNodeColorMap(nodeNames) {
-                return Object.fromEntries(Object.entries(nodeNames).map(([id, name]) => [id, getRandomColor(name)]));
+            for (let [key, value] of formData.entries()) {
+                value ? urlParams.set(key, value) : urlParams.delete(key);
             }
 
-            function getRandomColor(name) {
-                let hash = 0;
-                for (let i = 0; i < name.length; i++) {
-                    hash = name.charCodeAt(i) + ((hash << 5) - hash);
-                }
-                const hue = (hash % 360 + Math.random() * 50) % 360;
-                const saturation = 50 + (hash % 30);
-                const lightness = 40 + (hash % 20);
-                return `hsla(${hue}, ${saturation}%, ${lightness}%, 0.55)`;
-            }
+            window.location.href = `${window.location.pathname}?${urlParams.toString()}`;
+        };
 
-            function generateDatasets(flows) {
-                const dataByNode = flows.reduce((acc, flow) => {
-                    acc[flow.id] = acc[flow.id] || [];
-                    acc[flow.id].push({
-                        time: flow.time,
-                        total: parseFloat(flow.total),
-                        name: flow.name
-                    });
-                    return acc;
-                }, {});
-
-                return Object.entries(dataByNode).map(([nodeId, data]) => ({
-                    label: data[0].name,
-                    backgroundColor: nodeColorMap[nodeId],
-                    borderColor: nodeColorMap[nodeId],
-                    data,
-                    fill: true,
-                }));
+        const resetSearchForm = () => {
+            window.location.href = window.location.href.split('?')[0];
+        };
+
+        const initCharts = () => {
+            if (userData && Object.keys(userData).length > 2) {
+                const nodeColorMap = generateNodeColorMap(userData.nodes);
+                createBarChart('hourlyBar', userData.hours.map(String), generateDatasets(userData.hourlyFlows, nodeColorMap), @json(trans_choice('common.hour', 2)));
+                createBarChart('dailyBar', userData.days, generateDatasets(userData.dailyFlows, nodeColorMap), @json(trans_choice('common.days.attribute', 2)));
             }
+        };
 
-            // 创建图表
-            function initCharts() {
-                createBarChart('hourlyBar', userData.hours, generateDatasets(userData.hourlyFlows), @json(trans_choice('common.hour', 2)));
-                createBarChart('dailyBar', userData.days, generateDatasets(userData.dailyFlows), @json(trans_choice('common.days.attribute', 2)));
+        document.addEventListener('DOMContentLoaded', () => {
+            const hourDateSelect = document.getElementById('hour_date');
+            if (hourDateSelect) {
+                hourDateSelect.addEventListener('change', (event) => handleFormSubmit(event, event.target.form));
+                $(hourDateSelect).selectpicker('val', new URLSearchParams(window.location.search).get('hour_date') || @json(now()->format('Y-m-d')));
             }
 
-            $(document).ready(function() {
-                handleFormSubmit();
-                initCharts();
+            $('.input-daterange').datepicker({
+                format: 'yyyy-mm-dd',
+                startDate: userData.start_date,
+                endDate: new Date(),
             });
-        </script>
-    @endisset
+
+            initCharts();
+        });
+
+        window.handleFormSubmit = handleFormSubmit;
+        window.resetSearchForm = resetSearchForm;
+    </script>
 @endsection

+ 4 - 3
resources/views/user/layouts.blade.php

@@ -190,11 +190,12 @@
     </div>
     <footer class="site-footer">
         <div class="site-footer-legal">
-            © 2017 - 2024 <a href="https://github.com/ProxyPanel/ProxyPanel" target="_blank">{{config('version.name')}} {{__('All rights reserved.')}}</a>
-            🚀 Version: <code> {{config('version.number')}} </code>
+            © 2017 - {{ now()->year }} <a href="https://github.com/ProxyPanel/ProxyPanel" target="_blank">{{ config('version.name') }}
+                {{ __('All rights reserved.') }}</a>
+            🚀 Version: <code> {{ config('version.number') }} </code>
         </div>
         <div class="site-footer-right">
-            <a href="{{sysConfig('website_url')}}" target="_blank">{{sysConfig('website_name')}}</a> 🈺
+            <a href="{{ sysConfig('website_url') }}" target="_blank">{{ sysConfig('website_name') }}</a> 🈺
         </div>
     </footer>
     @if (Session::has('admin'))

+ 3 - 2
resources/views/vendor/log-viewer/remark/layouts.blade.php

@@ -178,8 +178,9 @@
         </div>
     </div>
     <footer class="site-footer ml-0">
-        <div class="site-footer-legal">© 2017 -
-            2024<a href="https://github.com/ProxyPanel/ProxyPanel" target="_blank">{{ config('version.name') }} </a> {{ __('All rights reserved.') }}
+        <div class="site-footer-legal">
+            © 2017 - {{ now()->year }}<a href="https://github.com/ProxyPanel/ProxyPanel" target="_blank">{{ config('version.name') }}</a>
+            {{ __('All rights reserved.') }}
         </div>
         <div class="site-footer-right">
             Base on <a href="https://github.com/ARCANEDEV/LogViewer" target="_blank">LogViewer</a> 🚀