Bläddra i källkod

New Report: Node Traffic Analysis

- update stack chat config;
BrettonYe 1 år sedan
förälder
incheckning
aaa9f0e19b

+ 110 - 13
app/Http/Controllers/Admin/ReportController.php

@@ -8,6 +8,7 @@ use App\Models\NodeDailyDataFlow;
 use App\Models\NodeHourlyDataFlow;
 use App\Models\Order;
 use App\Models\User;
+use App\Models\UserDataFlowLog;
 use Carbon\Carbon;
 use DB;
 use Illuminate\Contracts\View\View;
@@ -67,31 +68,26 @@ class ReportController extends Controller
                 'total' => round($item->total / MiB, 2),
             ]);
 
-            $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
-            ) => [
+            $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),
             ]); // 用户今天各小时在各线路消耗流量
 
-            $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
-            ) => [
+            $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),
             ]);
 
-            $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();
+            $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],
@@ -105,6 +101,106 @@ class ReportController extends Controller
         return view('admin.report.userDataAnalysis', compact('data'));
     }
 
+    public function nodeAnalysis(Request $request)
+    {
+        $currentTime = now();
+        $currentDate = $currentTime->format('m-d');
+        $currentHour = $currentTime->hour;
+        $nodeId = $request->input('nodes');
+        $startDate = $request->input('start') ?? $currentTime->format('Y-m-01');
+        $endDate = $request->input('end') ?? $currentTime->format('Y-m-d');
+        $hour_date = $request->input('hour_date') ?? $currentTime; // 默认是今天
+
+        $nodes = Node::orderBy('name')->pluck('name', 'id'); // 用于前端节点显示
+
+        $currentHourQuery = UserDataFlowLog::query();
+        $hourlyQuery = NodeHourlyDataFlow::query();
+        $dailyQuery = NodeDailyDataFlow::query();
+
+        if ($nodeId) { // 节点过滤
+            $currentHourQuery->whereIn('node_id', $nodeId);
+            $hourlyQuery->whereIn('node_id', $nodeId);
+            $dailyQuery->whereIn('node_id', $nodeId);
+        }
+
+        $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],
+            'start_date' => Carbon::parse(NodeDailyDataFlow::orderBy('created_at')->value('created_at'))->format('Y-m-d'), // 数据库里最早的日期
+        ];
+
+        $hoursFlow = $hourlyQuery->whereDate('created_at', $hour_date)->selectRaw('node_id, HOUR(created_at) as hour, u + d as total')->get()->map(fn ($item) => [
+            'id' => $item->node_id,
+            'name' => $nodes[$item->node_id],
+            'time' => (int) $item->hour,
+            'total' => round($item->total / GiB, 2),
+        ])->toArray(); // 各线路小时消耗流量
+
+        $daysFlow = $dailyQuery->whereNotNull('node_id')->whereDate('created_at', '>=', $startDate)->whereDate('created_at', '<=', $endDate)->selectRaw('node_id, DATE_FORMAT(created_at, "%m-%d") as date, u + d as total')->get()->map(fn ($item) => [
+            'id' => $item->node_id,
+            'name' => $nodes[$item->node_id],
+            'time' => $item->date,
+            'total' => round($item->total / GiB, 2),
+        ])->toArray();
+
+        if (Carbon::parse($hour_date)->isToday()) { // 如果日期是今天,本小时流量需要另外计算
+            $currentHourFlow = $currentHourQuery->where('log_time', '>=', $currentTime->startOfHour()->timestamp)->groupBy('node_id')->selectRaw('node_id, sum(u + d) as total')->get()->map(fn ($item) => [
+                'id' => $item->node_id,
+                'name' => $nodes[$item->node_id],
+                'time' => $currentHour,
+                'total' => round($item->total / GiB, 2),
+            ])->toArray();
+
+            $hoursFlow = array_merge($hoursFlow, $currentHourFlow);
+
+            if (Carbon::parse($endDate)->isToday()) {
+                $currentDayFlow = collect($hoursFlow)->groupBy('id')->map(fn ($items) => [
+                    'id' => $items->first()['id'],
+                    'name' => $items->first()['name'],
+                    'time' => $currentDate,
+                    'total' => round($items->sum('total'), 2),
+                ])->values()->toArray();
+
+                $daysFlow = array_merge($daysFlow, $currentDayFlow);
+            }
+        } elseif (Carbon::parse($endDate)->isToday()) { // 如果结束日期是今天,本日流量需要另外计算
+            $todayHourlyQuery = NodeHourlyDataFlow::query();
+
+            if ($nodeId) { // 节点过滤
+                $todayHourlyQuery->whereIn('node_id', $nodeId);
+            }
+
+            $hoursFlowToday = $todayHourlyQuery->whereDate('created_at', $currentTime)->selectRaw('node_id, HOUR(created_at) as hour, u + d as total')->get()->map(fn ($item) => [
+                'id' => $item->node_id,
+                'name' => $nodes[$item->node_id],
+                'time' => (int) $item->hour,
+                'total' => $item->total / GiB,
+            ])->toArray();
+
+            $currentHourFlow = $currentHourQuery->where('log_time', '>=', $currentTime->startOfHour()->timestamp)->groupBy('node_id')->selectRaw('node_id, sum(u + d) as total')->get()->map(fn ($item) => [
+                'id' => $item->node_id,
+                'name' => $nodes[$item->node_id],
+                'time' => $currentHour,
+                'total' => $item->total / GiB,
+            ])->toArray();
+
+            $currentDayFlow = collect($currentHourFlow)->merge($hoursFlowToday)->groupBy('id')->map(fn ($items) => [
+                'id' => $items->first()['id'],
+                'name' => $items->first()['name'],
+                'time' => $currentDate,
+                'total' => round($items->sum('total'), 2),
+            ])->values()->toArray();
+
+            $daysFlow = array_merge($daysFlow, $currentDayFlow);
+        }
+
+        $data['hourlyFlows'] = $hoursFlow;
+        $data['dailyFlows'] = $daysFlow;
+        $data['nodes'] = collect($daysFlow)->pluck('name', 'id')->unique()->toArray();
+        $hour_dates = NodeHourlyDataFlow::selectRaw('DISTINCT DATE_FORMAT(created_at, "%Y-%m-%d") as formatted_date')->orderByDesc('formatted_date')->pluck('formatted_date')->toArray();
+
+        return view('admin.report.nodeDataAnalysis', compact('data', 'nodes', 'hour_dates'));
+    }
+
     public function siteAnalysis(Request $request): View
     {
         $nodeId = $request->input('node_id');
@@ -131,6 +227,7 @@ class ReportController extends Controller
 
         $daysWithData = max($trafficData->dataCounts ?? 0, 1);
         $months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
+
         $data = [
             'days' => range(1, max($currentDays, $lastDays)),
             'months' => array_map(static fn ($month) => Carbon::create(null, $month)->translatedFormat('F'), $months),

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 2 - 2
public/assets/global/vendor/chart-js/chart.min.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/global/vendor/chart-js/chartjs-plugin-datalabels.min.js


+ 5 - 2
resources/lang/de/admin.php

@@ -84,6 +84,7 @@ return [
             'attribute' => 'Analytik',
             'accounting' => 'Buchhaltung',
             'user_flow' => 'Benutzerverkehrsanalyse',
+            'node_flow' => 'Knotenverkehrsanalyse',
             'site_flow' => 'Seitenverkehrsanalyse',
         ],
         'log' => [
@@ -373,9 +374,11 @@ return [
         'last_year' => 'Letztes Jahr',
         'hourly_traffic' => 'Stündlicher Datenverkehr',
         'daily_traffic' => 'Täglicher Datenverkehr',
+        'daily_distribution' => 'Tägliche Verteilung',
         'today' => 'Heute',
         'avg_traffic_30d' => 'Durchschnittlicher täglicher Datenverkehr über 30 Tage',
         'sum_traffic_30d' => '30-Tage-Datenverkehrsverhältnis',
+        'select_hourly_date' => 'Wählen Sie das Stunden-Datum',
     ],
     'permission' => [
         'title' => 'Berechtigungen',
@@ -626,7 +629,7 @@ return [
             'captcha_key' => 'Anleitung <a href="https://proxypanel.gitbook.io/wiki/captcha" target="_blank">hier</a>',
             'data_anomaly_notification' => 'Benachrichtige Admin, wenn stündliche Daten den Schwellenwert überschreiten',
             'data_exhaust_notification' => 'Benachrichtige, wenn Daten fast aufgebraucht sind',
-            'ddns_key' => "Anleitung <a href='https://proxypanel.gitbook.io/wiki/ddns' target='_blank'>hier</a>",
+            'ddns_key' => 'Anleitung <a href="https://proxypanel.gitbook.io/wiki/ddns" target="_blank">hier</a>',
             'ddns_mode' => 'Synchronisiere Domain- und IP-Änderungen mit DNS-Anbieter',
             'default_days' => 'Standard-Ablaufzeit für neue Konten, 0 bedeutet heute ablaufen',
             'default_traffic' => 'Standard-Daten für neue Konten',
@@ -680,7 +683,7 @@ return [
             'register_ip_limit' => 'Anzahl der Registrierungen pro IP in 24 Stunden, 0 für unbegrenzt',
             'reset_password_times' => 'Anzahl der Passwortzurücksetzungen per E-Mail in 24 Stunden',
             'reset_traffic' => 'Automatische Datenrücksetzung basierend auf Benutzerplanzyklus',
-            'server_chan_key' => 'Fülle <a href=https://sc.ftqq.com target=_blank>ServerChan SCKEY</a> aus, bevor aktiviert',
+            'server_chan_key' => 'Fülle <a href="https://sct.ftqq.com/r/2626" target="_blank">ServerChan SCKEY</a> aus, bevor aktiviert',
             'standard_currency' => 'Primärwährung im Panel',
             'subject_name' => 'Benutzerdefinierter Produktname in Zahlungsgateways',
             'subscribe_ban_times' => 'Maximale Abonnementanfragen pro Benutzer in 24 Stunden',

+ 5 - 2
resources/lang/en/admin.php

@@ -84,6 +84,7 @@ return [
             'attribute' => 'Analytics',
             'accounting' => 'Accounting',
             'user_flow' => 'User Traffic Analysis',
+            'node_flow' => 'Node Traffic Analysis',
             'site_flow' => 'Site Traffic Analysis',
         ],
         'log' => [
@@ -373,9 +374,11 @@ return [
         'last_year' => 'Last Year',
         'hourly_traffic' => 'Hourly Traffic',
         'daily_traffic' => 'Daily Traffic',
+        'daily_distribution' => 'Daily distribution',
         'today' => 'Today',
         'avg_traffic_30d' => 'Avg Daily Traffic Over 30 Days',
         'sum_traffic_30d' => '30-Day Traffic Ratio',
+        'select_hourly_date' => 'Select Hourly Date',
     ],
     'permission' => [
         'title' => 'Permissions',
@@ -626,7 +629,7 @@ return [
             'captcha_key' => 'Browse <a href="https://proxypanel.gitbook.io/wiki/captcha" target="_blank">setup guide</a>',
             'data_anomaly_notification' => 'Notify admin when hourly data exceeds threshold',
             'data_exhaust_notification' => 'Notify when data is running out',
-            'ddns_key' => "Browse <a href='https://proxypanel.gitbook.io/wiki/ddns' target='_blank'>setup guide</a>",
+            'ddns_key' => 'Browse<a href="https://proxypanel.gitbook.io/wiki/ddns" target="_blank">Setup Guide</a>to set up',
             'ddns_mode' => 'Sync domain & IP changes to DNS provider',
             'default_days' => 'Default expiration for new accounts, 0 means expire today',
             'default_traffic' => 'Default data for new accounts',
@@ -680,7 +683,7 @@ return [
             'register_ip_limit' => 'Number of registrations allowed per IP in 24 hours, 0 for unlimited',
             'reset_password_times' => 'Number of password resets allowed via email in 24 hours',
             'reset_traffic' => 'Automatically reset data based on user plan cycle',
-            'server_chan_key' => 'Fill in <a href=https://sc.ftqq.com target=_blank>ServerChan SCKEY</a> before enabling',
+            'server_chan_key' => 'Fill in <a href="https://sct.ftqq.com/r/2626" target="_blank">ServerChan SCKEY</a> before enabling',
             'standard_currency' => 'Primary currency used in panel',
             'subject_name' => 'Custom product name in payment gateways',
             'subscribe_ban_times' => 'Maximum subscription requests allowed per user in 24 hours',

+ 5 - 2
resources/lang/fa/admin.php

@@ -84,6 +84,7 @@ return [
             'attribute' => 'تحلیل داده‌ها',
             'accounting' => 'حسابداری',
             'user_flow' => 'تحلیل ترافیک کاربران',
+            'node_flow' => 'تحلیل ترافیک گره',
             'site_flow' => 'تحلیل ترافیک سایت',
         ],
         'log' => [
@@ -373,9 +374,11 @@ return [
         'last_year' => 'سال گذشته',
         'hourly_traffic' => 'ترافیک ساعتی',
         'daily_traffic' => 'ترافیک روزانه',
+        'daily_distribution' => 'توزیع روزانه',
         'today' => 'امروز',
         'avg_traffic_30d' => 'میانگین ترافیک روزانه در 30 روز',
         'sum_traffic_30d' => 'نسبت ترافیک 30 روزه',
+        'select_hourly_date' => 'انتخاب تاریخ ساعتی',
     ],
     'permission' => [
         'title' => 'دسترسی‌ها',
@@ -626,7 +629,7 @@ return [
             'captcha_key' => 'راهنمای تنظیمات <a href="https://proxypanel.gitbook.io/wiki/captcha" target="_blank">اینجا</a>',
             'data_anomaly_notification' => 'اطلاع‌رسانی به مدیر در صورت استفاده غیرعادی از داده در یک ساعت',
             'data_exhaust_notification' => 'اطلاع‌رسانی در صورت اتمام داده',
-            'ddns_key' => "راهنمای تنظیمات <a href='https://proxypanel.gitbook.io/wiki/ddns' target='_blank'>اینجا</a>",
+            'ddns_key' => 'راهنمای تنظیمات <a href="https://proxypanel.gitbook.io/wiki/ddns" target="_blank">اینجا</a>',
             'ddns_mode' => 'به‌روزرسانی خودکار تغییرات دامنه و IP به DNS',
             'default_days' => 'مدت اعتبار پیش‌فرض حساب‌های جدید، 0 به‌معنای انقضا در همان روز',
             'default_traffic' => 'داده پیش‌فرض برای حساب‌های جدید',
@@ -680,7 +683,7 @@ return [
             'register_ip_limit' => 'تعداد ثبت‌نام‌های مجاز در هر IP در 24 ساعت، 0 برای نامحدود',
             'reset_password_times' => 'تعداد مجاز بازنشانی رمز عبور از طریق ایمیل در 24 ساعت',
             'reset_traffic' => 'بازنشانی خودکار داده بر اساس چرخه طرح کاربر',
-            'server_chan_key' => 'پر کردن <a href=https://sc.ftqq.com target=_blank>کلید ServerChan</a> قبل از فعال‌سازی',
+            'server_chan_key' => 'پر کردن <a href="https://sct.ftqq.com/r/2626" target="_blank">کلید ServerChan</a> قبل از فعال‌سازی',
             'standard_currency' => 'واحد پول اصلی مورد استفاده در پنل',
             'subject_name' => 'نام محصول سفارشی در درگاه‌های پرداخت',
             'subscribe_ban_times' => 'حداکثر تعداد درخواست‌های اشتراک در 24 ساعت',

+ 5 - 2
resources/lang/ja/admin.php

@@ -84,6 +84,7 @@ return [
             'attribute' => 'データ分析',
             'accounting' => '会計',
             'user_flow' => 'ユーザートラフィック',
+            'node_flow' => 'ノードトラフィック分析',
             'site_flow' => 'サイトトラフィック',
         ],
         'log' => [
@@ -373,9 +374,11 @@ return [
         'last_year' => '去年',
         'hourly_traffic' => '毎時トラフィック',
         'daily_traffic' => '日次トラフィック',
+        'daily_distribution' => '日次分布',
         'today' => '今日',
         'avg_traffic_30d' => '過去30日間の平均日次トラフィック',
         'sum_traffic_30d' => '過去30日間のトラフィック比率',
+        'select_hourly_date' => '時間別の日付を選択',
     ],
     'permission' => [
         'title' => '権限行動リスト',
@@ -626,7 +629,7 @@ return [
             'captcha_key' => '設定ガイドは<a href="https://proxypanel.gitbook.io/wiki/captcha" target="_blank">こちら</a>を参照してください。',
             'data_anomaly_notification' => '1時間以内にデータ使用量が異常な閾値を超えた場合、管理者に通知します。',
             'data_exhaust_notification' => 'ユーザーにデータ使用量が尽きる前に通知します。',
-            'ddns_key' => "設定ガイドは<a href='https://proxypanel.gitbook.io/wiki/ddns' target='_blank'>こちら</a>を参照してください。",
+            'ddns_key' => '設定ガイドは<a href="https://proxypanel.gitbook.io/wiki/ddns" target="_blank">こちら</a>を参照してください。',
             'ddns_mode' => 'ノードの「ドメイン、IPv4、IPv6」を追加/編集/削除するときに、対応する内容をDNSプロバイダーに自動更新します。',
             'default_days' => 'ユーザー登録時のデフォルトのアカウント有効期限。0の場合は当日有効期限。',
             'default_traffic' => 'ユーザー登録時のデフォルトのデータ量。',
@@ -680,7 +683,7 @@ return [
             'register_ip_limit' => '24時間以内に同一IPで許可される登録数。0または空白の場合は無制限。',
             'reset_password_times' => '24時間以内にメールでパスワードをリセットできる回数。',
             'reset_traffic' => 'ユーザーは購入したプランの日付に基づいて自動的にデータがリセットされます。',
-            'server_chan_key' => 'ServerChanを有効にするには、この値を必ず入力してください(<a href=https://sc.ftqq.com target=_blank>SCKEYを申請</a>)。',
+            'server_chan_key' => 'ServerChanを有効にするには、この値を必ず入力してください(<a href="https://sct.ftqq.com/r/2626" target="_blank">SCKEYを申請</a>)。',
             'standard_currency' => 'ウェブサイトで使用されるデフォルトの通貨。',
             'subject_name' => '支払いチャネルで表示される商品のタイトル。',
             'subscribe_ban_times' => '24時間以内にサブスクリプションリンクリクエストの回数制限。',

+ 4 - 1
resources/lang/ko/admin.php

@@ -84,6 +84,7 @@ return [
             'attribute' => '데이터 분석',
             'accounting' => '회계',
             'user_flow' => '사용자 트래픽',
+            'node_flow' => '노드 트래픽 분석',
             'site_flow' => '사이트 트래픽',
         ],
         'log' => [
@@ -373,9 +374,11 @@ return [
         'last_year' => '작년',
         'hourly_traffic' => '시간별 트래픽',
         'daily_traffic' => '일일 트래픽',
+        'daily_distribution' => '일일 분포',
         'today' => '오늘',
         'avg_traffic_30d' => '30일 평균 일일 트래픽',
         'sum_traffic_30d' => '30일 총 트래픽 비율',
+        'select_hourly_date' => '시간별 날짜 선택',
     ],
     'permission' => [
         'title' => '권한 행동 목록',
@@ -680,7 +683,7 @@ return [
             'register_ip_limit' => '24시간 내 동일 IP에서 허용되는 등록 수, 0 또는 비워두면 무제한',
             'reset_password_times' => '24시간 내 이메일을 통해 비밀번호 재설정 가능한 횟수',
             'reset_traffic' => '사용자가 구매한 패키지의 날짜에 따라 자동으로 데이터 재설정',
-            'server_chan_key' => 'ServerChan을 활성화하려면 반드시 이 값을 입력하세요(<a href="https://sc.ftqq.com" target="_blank">SCKEY 신청</a>)',
+            'server_chan_key' => 'ServerChan을 활성화하려면 반드시 이 값을 입력하세요(<a href="https://sct.ftqq.com/r/2626" target="_blank">SCKEY 신청</a>)',
             'standard_currency' => '웹사이트에서 사용되는 기본 통화',
             'subject_name' => '결제 채널에서 표시되는 상품 제목',
             'subscribe_ban_times' => '24시간 내 구독 링크 요청 횟수 제한',

+ 5 - 2
resources/lang/vi/admin.php

@@ -84,6 +84,7 @@ return [
             'attribute' => 'Phân tích dữ liệu',
             'accounting' => 'Sổ kế toán',
             'user_flow' => 'Lưu lượng người dùng',
+            'node_flow' => 'Phân tích lưu lượng của nút',
             'site_flow' => 'Lưu lượng trang web',
         ],
         'log' => [
@@ -373,9 +374,11 @@ return [
         'last_year' => 'Năm trước',
         'hourly_traffic' => 'Lưu lượng hàng giờ',
         'daily_traffic' => 'Lưu lượng hàng ngày',
+        'daily_distribution' => 'Phân phối hàng ngày',
         'today' => 'Hôm nay',
         'avg_traffic_30d' => 'Lưu lượng trung bình 30 ngày',
         'sum_traffic_30d' => 'Tỷ lệ lưu lượng 30 ngày',
+        'select_hourly_date' => 'Chọn ngày theo giờ',
     ],
     'permission' => [
         'title' => 'Danh sách quyền hạn',
@@ -626,7 +629,7 @@ return [
             'captcha_key' => 'Xem <a href="https://proxypanel.gitbook.io/wiki/captcha" target="_blank">hướng dẫn cài đặt</a>',
             'data_anomaly_notification' => 'Thông báo cho quản trị viên khi lưu lượng hàng giờ vượt quá ngưỡng',
             'data_exhaust_notification' => 'Thông báo khi lưu lượng sắp hết',
-            'ddns_key' => "Xem <a href='https://proxypanel.gitbook.io/wiki/ddns' target='_blank'>hướng dẫn cài đặt</a>",
+            'ddns_key' => 'Xem <a href="https://proxypanel.gitbook.io/wiki/ddns" target="_blank">hướng dẫn cài đặt</a>',
             'ddns_mode' => 'Đồng bộ thay đổi tên miền & IP với nhà cung cấp DNS',
             'default_days' => 'Thời hạn mặc định cho tài khoản mới, 0 nghĩa là hết hạn ngay hôm nay',
             'default_traffic' => 'Lưu lượng mặc định cho tài khoản mới',
@@ -680,7 +683,7 @@ return [
             'register_ip_limit' => 'Số lượng đăng ký cho phép mỗi IP trong 24 giờ, 0 là không giới hạn',
             'reset_password_times' => 'Số lần đặt lại mật khẩu qua email trong 24 giờ',
             'reset_traffic' => 'Tự động đặt lại lưu lượng dựa trên chu kỳ gói của người dùng',
-            'server_chan_key' => 'Điền <a href=https://sc.ftqq.com target=_blank>ServerChan SCKEY</a> trước khi bật',
+            'server_chan_key' => 'Điền <a href="https://sct.ftqq.com/r/2626" target="_blank">ServerChan SCKEY</a> trước khi bật',
             'standard_currency' => 'Tiền tệ chính sử dụng trong bảng điều khiển',
             'subject_name' => 'Tên sản phẩm tùy chỉnh trong các cổng thanh toán',
             'subscribe_ban_times' => 'Số lượng yêu cầu đăng ký tối đa mỗi người dùng trong 24 giờ',

+ 6 - 3
resources/lang/zh_CN/admin.php

@@ -84,6 +84,7 @@ return [
             'attribute' => '数据分析',
             'accounting' => '流水账',
             'user_flow' => '用户流量',
+            'node_flow' => '节点流量',
             'site_flow' => '本站流量',
         ],
         'log' => [
@@ -371,11 +372,13 @@ return [
         'last_month' => '上 月',
         'current_year' => '今 年',
         'last_year' => '去 年',
-        'hourly_traffic' => '每时流量',
-        'daily_traffic' => '每天流量',
+        'hourly_traffic' => '小时流量',
+        'daily_traffic' => '日流量',
+        'daily_distribution' => '日分布',
         'today' => '本 日',
         'avg_traffic_30d' => '30天日均流量',
         'sum_traffic_30d' => '30天占总流量比',
+        'select_hourly_date' => '选择小时日期',
     ],
     'permission' => [
         'title' => '权限行为列表',
@@ -626,7 +629,7 @@ return [
             'captcha_key' => '浏览<a href="https://proxypanel.gitbook.io/wiki/captcha" target="_blank">设置指南</a>来设置',
             'data_anomaly_notification' => '1小时内流量超过异常阈值将通知管理员',
             'data_exhaust_notification' => '通知用户流量即将耗尽',
-            'ddns_key' => "浏览<a href='https://proxypanel.gitbook.io/wiki/ddns' target='_blank'>设置指南</a>来设置",
+            'ddns_key' => '浏览<a href="https://proxypanel.gitbook.io/wiki/ddns" target="_blank">设置指南</a>来设置',
             'ddns_mode' => '添加/编辑/删除节点的【域名、ipv4、ipv6】时,自动更新对应内容至DNS服务商',
             'default_days' => '用户注册时的默认账户有效期,为0即当天到期',
             'default_traffic' => '用户注册时默认可用流量',

+ 7 - 0
resources/views/admin/layouts.blade.php

@@ -376,6 +376,13 @@
                                     </a>
                                 </li>
                             @endcan
+                            @can('admin.report.nodeAnalysis')
+                                <li class="site-menu-item {{ request()->routeIs('admin.report.nodeAnalysis') ? 'active open' : '' }}">
+                                    <a href="{{ route('admin.report.nodeAnalysis') }}">
+                                        <span class="site-menu-title">{{ trans('admin.menu.analysis.node_flow') }}</span>
+                                    </a>
+                                </li>
+                            @endcan
                             @can('admin.report.siteAnalysis')
                                 <li class="site-menu-item {{ request()->routeIs('admin.report.siteAnalysis') ? 'active open' : '' }}">
                                     <a href="{{ route('admin.report.siteAnalysis') }}">

+ 379 - 0
resources/views/admin/report/nodeDataAnalysis.blade.php

@@ -0,0 +1,379 @@
+@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-fluid w-xl-p75 w-xxl-p100">
+        <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>
+                            @foreach ($nodes as $id => $name)
+                                <option value="{{ $id }}">{{ $name }}</option>
+                            @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>
+                    </div>
+                </form>
+            </div>
+        </div>
+        @isset($data)
+            <div class="row">
+                <div class="col-md-12 col-xxl-7 card card-shadow">
+                    <div class="card-block p-30">
+                        <div class="row pb-20">
+                            <div class="col-md-8 col-sm-6">
+                                <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.hourly_traffic') }}</div>
+                            </div>
+                        </div>
+                        <canvas id="hourlyBar"></canvas>
+                    </div>
+                </div>
+                <div class="col-md-12 col-xxl-5 card card-shadow">
+                    <div class="card-block p-30">
+                        <div class="row pb-20">
+                            <div class="col-md-8 col-sm-6">
+                                <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.daily_distribution') }}</div>
+                            </div>
+                        </div>
+                        <canvas id="dailyPie"></canvas>
+                    </div>
+                </div>
+                <div class="col-12 offset-xxl-2 col-xxl-8 card card-shadow">
+                    <div class="card-block p-30">
+                        <div class="row pb-20">
+                            <div class="col-md-8 col-sm-6">
+                                <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.daily_traffic') }}</div>
+                            </div>
+                        </div>
+                        <canvas id="dailyBar"></canvas>
+                    </div>
+                </div>
+            </div>
+        @endisset
+    </div>
+@endsection
+@section('javascript')
+    <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 nodeData = @json($data);
+        const nodeColorMap = generateNodeColorMap(nodeData.nodes);
+
+        function resetSearchForm() {
+            window.location.href = window.location.href.split('?')[0];
+        }
+
+        function initDatepicker() {
+            $('.input-daterange').datepicker({
+                format: 'yyyy-mm-dd',
+                startDate: nodeData.start_date,
+                endDate: new Date(),
+            });
+        }
+
+        function cleanSubmit(form) {
+            $(form).find('input:not([type="submit"]), select').filter(function() {
+                return this.value === "";
+            }).prop('disabled', true);
+
+            setTimeout(function() {
+                $(form).find(':disabled').prop('disabled', false);
+            }, 0);
+        }
+
+        function handleFormSubmit() {
+            $('form').on('submit', function() {
+                cleanSubmit(this);
+            });
+        }
+
+        function initSelectors() {
+            $('#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')));
+        }
+
+        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;
+                });
+                return {
+                    time: date,
+                    data: filledData,
+                    total
+                };
+            });
+
+            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
+                },
+                plugins: [ChartDataLabels],
+                options: {
+                    parsing: {
+                        xAxisKey: 'time',
+                        yAxisKey: 'total'
+                    },
+                    scales: {
+                        x: {
+                            stacked: true
+                        },
+                        y: {
+                            stacked: true
+                        }
+                    },
+                    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;
+                            },
+                        },
+                    },
+                },
+            });
+        }
+
+        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) {
+            Chart.register({
+                id: 'totalLabel',
+                beforeDraw(chart) {
+                    const {
+                        ctx,
+                        chartArea,
+                        data
+                    } = chart;
+                    if (!chartArea || !data.datasets.length) return;
+
+                    const total = data.datasets[0].data.reduce((acc, val) => acc + val, 0);
+                    if (typeof total !== 'number') return;
+
+                    const {
+                        width,
+                        height,
+                        top,
+                        left
+                    } = chartArea;
+                    const text = `${date}\n\n${total.toFixed(2)} GiB`;
+                    ctx.save();
+                    ctx.font = 'bold 32px Roboto';
+                    ctx.fillStyle = 'black';
+                    ctx.textAlign = 'center';
+                    ctx.textBaseline = 'middle';
+                    const lineHeight = 40;
+                    text.split('\n').forEach((line, index) => {
+                        ctx.fillText(line, left + width / 2, top + height / 2 - lineHeight / 2 + index * lineHeight);
+                    });
+                    ctx.restore();
+                },
+            });
+
+            new Chart(document.getElementById(elementId), {
+                type: 'doughnut',
+                data: {
+                    labels,
+                    datasets: [{
+                        data,
+                        backgroundColor: colors
+                    }]
+                },
+                options: {
+                    responsive: true,
+                    plugins: {
+                        legend: {
+                            display: false
+                        },
+                        tooltip: {
+                            callbacks: {
+                                label: tooltipItem => {
+                                    const dataset = tooltipItem.dataset;
+                                    const currentValue = dataset.data[tooltipItem.dataIndex];
+                                    const label = tooltipItem.label || '';
+                                    return `${label}: ${currentValue.toFixed(2)} G`;
+                                },
+                            },
+                        },
+                        datalabels: {
+                            color: '#fff',
+                            formatter: (value, context) => {
+                                const total = context.dataset.data.reduce((sum, val) => sum + val, 0);
+                                const percentage = (value / total * 100).toFixed(1);
+                                const label = context.chart.data.labels[context.dataIndex];
+                                return percentage > 1 ? `${label} ${value.toFixed(2)}G ${percentage}%` : '';
+                            },
+                            anchor: "center",
+                            rotation: function(ctx) {
+                                const valuesBefore = ctx.dataset.data.slice(0, ctx.dataIndex).reduce((a, b) => a + b, 0);
+                                const sum = ctx.dataset.data.reduce((a, b) => a + b, 0);
+                                const rotation = ((valuesBefore + ctx.dataset.data[ctx.dataIndex] / 2) / sum * 360);
+                                return rotation < 180 ? rotation - 90 : rotation + 90;
+                            },
+                            font: {
+                                weight: 'bold',
+                                size: 16,
+                                family: 'Roboto'
+                            },
+                        },
+                    },
+                },
+                plugins: [ChartDataLabels, 'totalLabel'],
+            });
+        }
+
+        function generatePieData(flows) {
+            return {
+                labels: flows.map(flow => flow.name),
+                data: flows.map(flow => flow.total),
+                colors: flows.map(flow => nodeColorMap[flow.id]),
+            };
+        }
+
+        function initCharts() {
+            createBarChart('hourlyBar', nodeData.hours, generateDatasets(nodeData.hourlyFlows), @json(trans_choice('common.hour', 2)), ' GiB');
+            createBarChart('dailyBar', '', generateDatasets(nodeData.dailyFlows), '', ' 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);
+        }
+
+        $(document).ready(function() {
+            initDatepicker();
+            handleFormSubmit();
+            initSelectors();
+            initCharts();
+        });
+    </script>
+@endsection

+ 114 - 83
resources/views/admin/report/userDataAnalysis.blade.php

@@ -13,7 +13,7 @@
                     </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>
-                        <a class="btn btn-danger" href="{{ route('admin.report.userAnalysis') }}">{{ trans('common.reset') }}</a>
+                        <button class="btn btn-danger" type="button" onclick="resetSearchForm()">{{ trans('common.reset') }}</button>
                     </div>
                 </form>
             </div>
@@ -27,7 +27,6 @@
                         </div>
                     </div>
                     <canvas id="hourlyBar"></canvas>
-                    <canvas id="hourlyDoughnut"></canvas>
                 </div>
             </div>
             <div class="card card-shadow">
@@ -38,7 +37,6 @@
                         </div>
                     </div>
                     <canvas id="dailyBar"></canvas>
-                    <canvas id="dailyDoughnut"></canvas>
                 </div>
             </div>
         @endisset
@@ -47,139 +45,172 @@
 @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">
-            function createBarChart(elementId, labels, datasets, labelTail) {
+            const userData = @json($data);
+            const nodeColorMap = generateNodeColorMap(userData.nodes); // 获取所有节点名称并生成颜色映射
+
+            function resetSearchForm() {
+                window.location.href = window.location.href.split('?')[0];
+            }
+
+            function handleFormSubmit() {
+                $('form').on('submit', function() {
+                    $(this).find('input, select').each(function() {
+                        if (!$(this).val()) {
+                            $(this).remove();
+                        }
+                    });
+                });
+            }
+
+            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;
+                    });
+                    return {
+                        time: date,
+                        data: filledData,
+                        total
+                    };
+                });
+
+                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: labels,
-                        datasets: datasets,
+                        labels: optimizedDatasets[0].data.map(d => d.time),
+                        datasets: optimizedDatasets
                     },
+                    plugins: [ChartDataLabels],
                     options: {
                         parsing: {
                             xAxisKey: 'time',
-                            yAxisKey: 'total',
+                            yAxisKey: 'total'
                         },
                         scales: {
                             x: {
-                                stacked: true,
+                                stacked: true
                             },
                             y: {
-                                stacked: true,
-                            },
+                                stacked: true
+                            }
                         },
                         responsive: true,
                         plugins: {
                             legend: {
                                 labels: {
-                                    padding: 20,
+                                    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;
                                 },
                             },
-                            tooltip: label_callbacks(labelTail),
                         },
                     },
                 });
             }
 
-            function label_callbacks(tail) {
+            function label_callbacks(tail, unit) {
                 return {
                     mode: 'index',
                     intersect: false,
                     callbacks: {
-                        title: function(context) {
-                            return context[0].label + ' ' + tail;
-                        },
-                        label: function(context) {
-                            let label = context.dataset.label || '';
-                            if (label) {
-                                label += ': ';
-                            }
-                            if (context.parsed.y !== null) {
-                                label += context.parsed.y + ' MiB';
-                            }
-                            return label;
-                        },
-                    },
+                        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 userData = @json($data);
-            const nodeColorMap = generateNodeColorMap(userData.nodes); // 获取所有节点名称并生成颜色映射
-
             function generateNodeColorMap(nodeNames) {
-                const colorMap = {};
-                Object.entries(nodeNames).forEach(([id, name]) => {
-                    colorMap[id] = getRandomColor(name);
-                });
-                return colorMap;
+                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 hueOffset = hash % 360;
-                const hueRange = 20; // 色调范围
-
-                // 计算最终色调
-                const hue = (hueOffset + Math.random() * hueRange) % 360; // 确保 hue 在 0-359 之间
-
-                // 保持饱和度和亮度固定
-                const saturation = 70; // 保持饱和度较高
-                const lightness = 50; // 保持亮度适中
-
-                // 添加透明度
-                const alpha = 0.55; // 50% 透明度
-
-                return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
+                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 = {};
-
-                // 按节点 ID 分组数据
-                flows.forEach(flow => {
-                    if (!dataByNode[flow.id]) {
-                        dataByNode[flow.id] = [];
-                    }
-                    dataByNode[flow.id].push({
+                const dataByNode = flows.reduce((acc, flow) => {
+                    acc[flow.id] = acc[flow.id] || [];
+                    acc[flow.id].push({
                         time: flow.time,
-                        total: flow.total,
-                        name: flow.name,
+                        total: parseFloat(flow.total),
+                        name: flow.name
                     });
-                });
+                    return acc;
+                }, {});
 
-                // 创建 datasets 数组
-                let datasets = [];
-                for (const nodeId in dataByNode) {
-                    if (dataByNode.hasOwnProperty(nodeId)) {
-                        datasets.push({
-                            label: dataByNode[nodeId][0].name, // 使用 name 作为标签
-                            backgroundColor: nodeColorMap[nodeId],
-                            borderColor: nodeColorMap[nodeId],
-                            data: dataByNode[nodeId],
-                            fill: true,
-                        });
-                    }
-                }
-                return datasets;
+                return Object.entries(dataByNode).map(([nodeId, data]) => ({
+                    label: data[0].name,
+                    backgroundColor: nodeColorMap[nodeId],
+                    borderColor: nodeColorMap[nodeId],
+                    data,
+                    fill: true,
+                }));
             }
 
             // 创建图表
-            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)));
+            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).ready(function() {
+                handleFormSubmit();
+                initCharts();
+            });
         </script>
     @endisset
 @endsection

+ 7 - 5
resources/views/admin/table_layouts.blade.php

@@ -15,11 +15,13 @@
         }
 
         $('form').on('submit', function() {
-            $(this).find('input, select').each(function() {
-                if (!$(this).val()) {
-                    $(this).remove();
-                }
-            });
+            $(this).find('input:not([type="submit"]), select').filter(function() {
+                return this.value === "";
+            }).prop('disabled', true);
+
+            setTimeout(function() {
+                $(this).find(':disabled').prop('disabled', false);
+            }, 0);
         });
 
         $('select').on('change', function() {

+ 1 - 0
routes/admin.php

@@ -106,6 +106,7 @@ Route::prefix('admin')->name('admin.')->group(function () {
     Route::prefix('report')->name('report.')->controller(ReportController::class)->group(function () {
         Route::get('accounting', 'accounting')->name('accounting'); // 流水账簿
         Route::get('user/analysis', 'userAnalysis')->name('userAnalysis'); // 用户流量分析
+        Route::get('node/analysis', 'nodeAnalysis')->name('nodeAnalysis'); // 节点流量分析
         Route::get('site/analysis', 'siteAnalysis')->name('siteAnalysis'); // 网站流量分析
     });
 

Vissa filer visades inte eftersom för många filer har ändrats