浏览代码

Integrate Laravel Reverb for real-time node and payment events

This update adds Laravel Reverb as the broadcast driver, introduces event classes for node actions and payment status updates, and implements real-time node management (check, geo refresh, reload) with asynchronous jobs and broadcasting. The admin node UI is refactored to use modals and real-time updates via Echo, and frontend assets are updated to support Reverb. Composer and configuration files are updated for Reverb, and install scripts now handle Reverb setup. Payment status updates are now broadcast to the frontend for real-time feedback.
BrettonYe 2 天之前
父节点
当前提交
2e743bd669

+ 18 - 2
.env.example

@@ -1,5 +1,5 @@
 APP_NAME=ProxyPanel
-APP_ENV=local
+APP_ENV=production
 APP_KEY=
 APP_DEBUG=
 APP_URL=https://proxypanel.ddo.jp
@@ -18,7 +18,7 @@ DB_DATABASE=ProxyPanel
 DB_USERNAME=root
 DB_PASSWORD=root
 
-BROADCAST_DRIVER=redis
+BROADCAST_DRIVER=reverb
 CACHE_DRIVER=redis
 FILESYSTEM_DISK=local
 QUEUE_CONNECTION=redis
@@ -59,3 +59,19 @@ CONTACT_TELEGRAM=https://t.me/+nW8AwsPPUsliYzg1
 # 自建探针服务器配置 Self Host Probe Servers
 PROBE_SERVERS_DOMESTIC=
 PROBE_SERVERS_FOREIGN=
+
+# Reverb WebSocket 配置
+REVERB_APP_ID=
+REVERB_APP_KEY=
+REVERB_APP_SECRET=
+REVERB_SERVER_PATH=/broadcasting
+# 可选,广播域名 默认为发起请求域名
+REVERB_HOST=
+# 可选,REVERB后端默认为8080
+REVERB_SERVER_PORT=
+# 可选,REVERB前端 默认为443/80 取决于FORCE_HTTPS
+REVERB_PORT=
+
+VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
+VITE_REVERB_HOST="${REVERB_HOST}"
+VITE_REVERB_PORT="${REVERB_PORT}"

+ 31 - 0
app/Events/NodeActions.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class NodeActions implements ShouldBroadcastNow
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    public function __construct(
+        public string $type,
+        public array $data = [],
+        public ?int $nodeId = null
+    ) {
+    }
+
+    public function broadcastOn(): Channel
+    {
+        return new Channel('node.'.$this->type.'.'.($this->nodeId ?? 'all'));
+    }
+
+    public function broadcastAs(): string
+    {
+        return 'node.actions';
+    }
+}

+ 31 - 0
app/Events/PaymentStatusUpdated.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class PaymentStatusUpdated implements ShouldBroadcast
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    public function __construct(
+        public string $tradeNo,
+        public string $status,
+        public string $message
+    ) {
+    }
+
+    public function broadcastOn(): Channel
+    {
+        return new Channel('payment-status.'.$this->tradeNo);
+    }
+
+    public function broadcastAs(): string
+    {
+        return 'payment.status.updated';
+    }
+}

+ 80 - 42
app/Http/Controllers/Admin/NodeController.php

@@ -2,10 +2,12 @@
 
 namespace App\Http\Controllers\Admin;
 
+use App\Events\NodeActions;
 use App\Helpers\DataChart;
 use App\Helpers\ProxyConfig;
 use App\Http\Controllers\Controller;
 use App\Http\Requests\Admin\NodeRequest;
+use App\Jobs\Node\CheckNodeIp;
 use App\Jobs\VNet\reloadNode;
 use App\Models\Country;
 use App\Models\Label;
@@ -13,7 +15,6 @@ use App\Models\Level;
 use App\Models\Node;
 use App\Models\NodeCertificate;
 use App\Models\RuleGroup;
-use App\Utils\NetworkDetection;
 use Arr;
 use Exception;
 use Illuminate\Contracts\View\View;
@@ -241,58 +242,95 @@ class NodeController extends Controller
         return response()->json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('common.delete')])]);
     }
 
-    public function checkNode(Node $node): JsonResponse
-    { // 节点IP阻断检测
-        foreach ($node->ips() as $ip) {
-            $status = NetworkDetection::networkStatus($ip, $n->port ?? 22);
-            $data[$ip] = [trans("admin.network_status.{$status['icmp']}"), trans("admin.network_status.{$status['tcp']}")];
-        }
+    public function checkNode(?Node $node = null): JsonResponse
+    {
+        // 获取节点集合并预加载IP信息
+        $fields = ['id', 'name', 'is_ddns', 'server', 'ip', 'ipv6', 'port'];
+        $nodes = ($node ? collect([$node]) : Node::whereStatus(1)->select($fields)->get())->map(function ($n) {
+            $n->ips = $n->ips();
+
+            return $n;
+        });
+
+        // 构建节点列表信息
+        $nodeList = $nodes->mapWithKeys(function ($n) {
+            return [$n->id => ['name' => $n->name, 'ips' => $n->ips]];
+        })->toArray();
+
+        // 立即发送节点列表信息给前端
+        broadcast(new NodeActions('check', ['nodeList' => $nodeList], $node?->id));
 
-        return response()->json(['status' => 'success', 'title' => '['.$node->name.'] '.trans('admin.node.connection_test'), 'message' => $data ?? []]);
+        // 异步分发检测任务,提高响应速度
+        $nodes->each(function ($n) use ($node) {
+            dispatch(new CheckNodeIp($n->id, $n->ips, $n->port ?? 22, $node?->id));
+        });
+
+        return response()->json([
+            'status' => 'success',
+            'message' => trans('common.success_item', [
+                'attribute' => $node ? trans('admin.node.connection_test') : trans('admin.node.connection_test_all'),
+            ]),
+        ]);
     }
 
-    public function refreshGeo(?int $id = null): JsonResponse
-    { // 刷新节点地理位置
-        $ret = false;
-        if ($id) {
-            $node = Node::findOrFail($id);
-            $ret = $node->refresh_geo();
-        } else {
-            foreach (Node::whereStatus(1)->get() as $node) {
-                $result = $node->refresh_geo();
-                if ($result && ! $ret) {
-                    $ret = true;
+    public function refreshGeo(?Node $node = null): JsonResponse
+    {
+        $nodes = $node ? collect([$node]) : Node::whereStatus(1)->get();
+
+        // 发送节点列表信息
+        broadcast(new NodeActions('geo', ['nodeList' => $nodes->pluck('name', 'id')], $node?->id));
+
+        // 异步处理地理位置刷新
+        $nodes->each(function ($n) use ($node) {
+            dispatch(static function () use ($n, $node) {
+                $ret = ['nodeId' => $n->id];
+                try {
+                    $ret += $n->refresh_geo();
+                } catch (Exception $e) {
+                    Log::error("节点 [{$n->id}] 刷新地理位置失败: ".$e->getMessage());
+                    $ret += ['error' => $e->getMessage()];
                 }
-            }
-        }
 
-        if ($ret) {
-            return response()->json(['status' => 'success', 'message' => trans('common.success_item', ['attribute' => trans('common.update')])]);
-        }
+                broadcast(new NodeActions('geo', $ret, $node?->id));
+            });
+        });
 
-        return response()->json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('common.update')])]);
+        return response()->json([
+            'status' => 'success',
+            'message' => trans('common.success_item', [
+                'attribute' => $node ? trans('admin.node.refresh_geo') : trans('admin.node.refresh_geo_all'),
+            ]),
+        ]);
     }
 
-    public function reload(?int $id = null): JsonResponse
-    { // 重载节点
-        $ret = false;
-        if ($id) {
-            $node = Node::findOrFail($id);
-            $ret = (new reloadNode($node))->handle();
-        } else {
-            foreach (Node::whereStatus(1)->whereType(4)->get() as $node) {
-                $result = (new reloadNode($node))->handle();
-                if ($result && ! $ret) {
-                    $ret = true;
+    public function reload(?Node $node = null): JsonResponse
+    {
+        $nodes = $node ? collect([$node]) : Node::whereStatus(1)->whereType(4)->get();
+
+        // 发送节点列表信息
+        broadcast(new NodeActions('reload', ['nodeList' => $nodes->pluck('name', 'id')], $node?->id));
+
+        // 异步处理节点重载
+        $nodes->each(function ($n) use ($node) {
+            dispatch(static function () use ($n, $node) {
+                $ret = ['nodeId' => $n->id];
+                try {
+                    $ret += (new reloadNode($n))->handle();
+                } catch (Exception $e) {
+                    Log::error("节点 [{$n->id}] 重载失败: ".$e->getMessage());
+                    $ret += ['error' => $e->getMessage()];
                 }
-            }
-        }
 
-        if ($ret) {
-            return response()->json(['status' => 'success', 'message' => trans('common.success_item', ['attribute' => trans('admin.node.reload')])]);
-        }
+                broadcast(new NodeActions('reload', $ret, $node?->id));
+            });
+        });
 
-        return response()->json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('admin.node.reload')])]);
+        return response()->json([
+            'status' => 'success',
+            'message' => trans('common.success_item', [
+                'attribute' => $node ? trans('admin.node.reload') : trans('admin.node.reload_all'),
+            ]),
+        ]);
     }
 
     public function nodeMonitor(Node $node): View

+ 7 - 0
app/Http/Controllers/PaymentController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Events\PaymentStatusUpdated;
 use App\Models\Coupon;
 use App\Models\Goods;
 use App\Models\Order;
@@ -53,10 +54,16 @@ class PaymentController extends Controller
         $payment = Payment::whereTradeNo($request->input('trade_no'))->first();
         if ($payment) {
             if ($payment->status === 1) {
+                // 触发支付成功事件
+                broadcast(new PaymentStatusUpdated($payment->trade_no, 'success', trans('common.success_item', ['attribute' => trans('user.pay')])));
+
                 return response()->json(['status' => 'success', 'message' => trans('common.success_item', ['attribute' => trans('user.pay')])]);
             }
 
             if ($payment->status === -1) {
+                // 触发支付失败事件
+                broadcast(new PaymentStatusUpdated($payment->trade_no, 'error', trans('user.payment.order_creation.order_timeout')));
+
                 return response()->json(['status' => 'error', 'message' => trans('user.payment.order_creation.order_timeout')]);
             }
 

+ 42 - 0
app/Jobs/Node/CheckNodeIp.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Jobs\Node;
+
+use App\Events\NodeActions;
+use App\Utils\NetworkDetection;
+use Exception;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Log;
+
+class CheckNodeIp implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public function __construct(
+        public int $nodeId,
+        public array $ips,
+        public int $port,
+        public ?int $controllerNodeId = null
+    ) {
+    }
+
+    public function handle(): void
+    {
+        foreach ($this->ips as $ip) {
+            $ret = ['ip' => $ip, 'icmp' => 4, 'tcp' => 4, 'nodeId' => $this->nodeId];
+            try {
+                $status = NetworkDetection::networkStatus($ip, $this->port ?? 22);
+                $ret['icmp'] = $status['icmp'];
+                $ret['tcp'] = $status['tcp'];
+            } catch (Exception $e) {
+                Log::error("节点 [{$this->nodeId}] IP [$ip] 检测失败: ".$e->getMessage());
+            }
+
+            broadcast(new NodeActions('check', $ret, $this->controllerNodeId));
+        }
+    }
+}

+ 7 - 5
app/Jobs/VNet/reloadNode.php

@@ -33,25 +33,27 @@ class reloadNode implements ShouldQueue
         }
     }
 
-    public function handle(): bool
+    public function handle(): array
     {
         foreach ($this->nodes as $node) {
             $data = $node->getSSRConfig();
 
             if ($node->is_ddns) {
+                $result = ['list' => $node->server];
                 if (! $this->send($node->server.':'.$node->push_port, $node->auth->secret, $data)) {
-                    $result = false;
+                    $result['error'] = [$node->server];
                 }
             } else { // 多IP支持
-                foreach ($node->ips() as $ip) {
+                $result = ['list' => $node->ips()];
+                foreach ($result['list'] as $ip) {
                     if (! $this->send($ip.':'.$node->push_port, $node->auth->secret, $data)) {
-                        $result = false;
+                        $result['error'][] = $ip;
                     }
                 }
             }
         }
 
-        return $result ?? true;
+        return $result ?? [];
     }
 
     public function send(string $host, string $secret, array $data): bool

+ 8 - 4
app/Models/Node.php

@@ -132,9 +132,14 @@ class Node extends Model
             ->get();
     }
 
-    public function refresh_geo(): bool
+    public function refresh_geo(): array
     {
         $ip = $this->ips();
+        $geo = explode(',', $this->geo ?? '');
+        $ret = ['original' => [
+            isset($geo[0]) ? (float) trim($geo[0]) : null,
+            isset($geo[1]) ? (float) trim($geo[1]) : null,
+        ]];
         if ($ip !== []) {
             $data = IP::getIPGeo($ip[0]); // 复数IP都以第一个为准
 
@@ -142,12 +147,11 @@ class Node extends Model
                 self::withoutEvents(function () use ($data) {
                     $this->update(['geo' => ($data['latitude'] ?? null).','.($data['longitude'] ?? null)]);
                 });
-
-                return true;
+                $ret['update'] = [$data['latitude'] ?? null, $data['longitude'] ?? null];
             }
         }
 
-        return false;
+        return $ret;
     }
 
     public function ips(int $type = 4): array

+ 2 - 0
app/Utils/Library/PaymentHelper.php

@@ -2,6 +2,7 @@
 
 namespace App\Utils\Library;
 
+use App\Events\PaymentStatusUpdated;
 use App\Models\Payment;
 use App\Models\PaymentCallback;
 use App\Notifications\PaymentReceived;
@@ -84,6 +85,7 @@ class PaymentHelper
             $ret = $payment->order->complete();
             if ($ret) {
                 $payment->user->notify(new PaymentReceived($payment->order->sn, $payment->amount_tag));
+                broadcast(new PaymentStatusUpdated($tradeNo, 'success', trans('common.success_item', ['attribute' => trans('user.pay')]))); // 触发支付状态更新事件
             }
 
             return $ret;

+ 1 - 0
composer.json

@@ -26,6 +26,7 @@
     "laravel-notification-channels/telegram": "^5.0",
     "laravel/framework": "^10.8",
     "laravel/horizon": "^5.15",
+    "laravel/reverb": "^1.5",
     "laravel/sanctum": "^3.2",
     "laravel/socialite": "^5.6",
     "laravel/tinker": "^2.8",

+ 1 - 1
config/app.php

@@ -161,7 +161,7 @@ return [
          */
         App\Providers\AppServiceProvider::class,
         App\Providers\AuthServiceProvider::class,
-        // App\Providers\BroadcastServiceProvider::class,
+        App\Providers\BroadcastServiceProvider::class,
         App\Providers\EventServiceProvider::class,
         App\Providers\RouteServiceProvider::class,
         App\Providers\HorizonServiceProvider::class,

+ 16 - 0
config/broadcasting.php

@@ -30,6 +30,22 @@ return [
 
     'connections' => [
 
+        'reverb' => [
+            'driver' => 'reverb',
+            'key' => env('REVERB_APP_KEY'),
+            'secret' => env('REVERB_APP_SECRET'),
+            'app_id' => env('REVERB_APP_ID'),
+            'options' => [
+                'host' => env('REVERB_HOST'),
+                'port' => env('REVERB_PORT', env('FORCE_HTTPS', true) ? 443 : 80),
+                'scheme' => env('FORCE_HTTPS', true) ? 'https' : 'http',
+                'useTLS' => env('FORCE_HTTPS', true),
+            ],
+            'client_options' => [
+                // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
+            ],
+        ],
+
         'pusher' => [
             'driver' => 'pusher',
             'key' => env('PUSHER_APP_KEY'),

+ 95 - 0
config/reverb.php

@@ -0,0 +1,95 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Default Reverb Server
+    |--------------------------------------------------------------------------
+    |
+    | This option controls the default server used by Reverb to handle
+    | incoming messages as well as broadcasting message to all your
+    | connected clients. At this time only "reverb" is supported.
+    |
+    */
+
+    'default' => env('REVERB_SERVER', 'reverb'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Reverb Servers
+    |--------------------------------------------------------------------------
+    |
+    | Here you may define details for each of the supported Reverb servers.
+    | Each server has its own configuration options that are defined in
+    | the array below. You should ensure all the options are present.
+    |
+    */
+
+    'servers' => [
+
+        'reverb' => [
+            'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
+            'port' => env('REVERB_SERVER_PORT', 8080),
+            'path' => env('REVERB_SERVER_PATH', ''),
+            'hostname' => env('REVERB_HOST'),
+            'options' => [
+                'tls' => [],
+            ],
+            'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
+            'scaling' => [
+                'enabled' => env('REVERB_SCALING_ENABLED', false),
+                'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
+                'server' => [
+                    'url' => env('REDIS_URL'),
+                    'host' => env('REDIS_HOST', '127.0.0.1'),
+                    'port' => env('REDIS_PORT', '6379'),
+                    'username' => env('REDIS_USERNAME'),
+                    'password' => env('REDIS_PASSWORD'),
+                    'database' => env('REDIS_DB', '0'),
+                    'timeout' => env('REDIS_TIMEOUT', 60),
+                ],
+            ],
+            'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
+            'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
+        ],
+
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Reverb Applications
+    |--------------------------------------------------------------------------
+    |
+    | Here you may define how Reverb applications are managed. If you choose
+    | to use the "config" provider, you may define an array of apps which
+    | your server will support, including their connection credentials.
+    |
+    */
+
+    'apps' => [
+
+        'provider' => 'config',
+
+        'apps' => [
+            [
+                'key' => env('REVERB_APP_KEY'),
+                'secret' => env('REVERB_APP_SECRET'),
+                'app_id' => env('REVERB_APP_ID'),
+                'options' => [
+                    'host' => env('REVERB_HOST'),
+                    'port' => env('REVERB_PORT', env('FORCE_HTTPS', true) ? 443 : 80),
+                    'scheme' => env('FORCE_HTTPS', true) ? 'https' : 'http',
+                    'useTLS' => env('FORCE_HTTPS', true),
+                ],
+                'allowed_origins' => ['*'],
+                'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
+                'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
+                'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
+                'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
+            ],
+        ],
+
+    ],
+
+];

+ 43 - 20
install.sh

@@ -8,40 +8,63 @@ source scripts/lib.sh
 # 信号处理
 trap 'rm -f .env; exit' SIGINT SIGTSTP SIGTERM
 
+# ===============================================
+# 1. 初始化和环境检查
+# ===============================================
 # 清理不需要的文件
 clean_files
 
-# 安装依赖
-print_message "Checking server environment..." "检查服务器环境..."
-install_dependencies
+# 检查 Web 环境 (检查 PHP/Nginx/MySQL 等工具)
+print_message "Checking the web environment..." "检查 Web 运行环境..."
+check_web_environment
 
-# 检查环境
-print_message "Checking the panel environment..." "检查面板运行环境..."
-check_env
+# ===============================================
+# 2. 核心依赖安装 (Composer, Supervisor, Node.js/npm)
+# ===============================================
+print_message "Checking/Installing core system dependencies..." " 检查/安装核心系统依赖项..."
+# 安装 Composer
+install_composer
+# 安装 Supervisor (Horizon/Reverb 所需)
+install_supervisor
+# 安装 Node.js/npm (前端构建所需)
+install_nodejs_npm
 
-# 设置权限
-print_message "Setting Folder Permissions..." "设置文件夹权限..."
-set_permissions
-
-# 检查Composer
-print_message "Checking Composer..." "检查Composer..."
-check_composer
-
-# 执行Composer安装
-print_message "Installing packages via Composer..." "通过Composer安装程序包..."
+# ===============================================
+# 3. 应用程序安装与配置
+# ===============================================
+# 执行 Composer 安装
+print_message "Installing packages via Composer..." "通过 Composer 安装程序包..."
 composer install --no-interaction --no-dev --optimize-autoloader
 
-# 执行Panel安装
+# 执行 Panel 安装 (Handles .env, DB, key:generate, storage:link)
 php artisan panel:install
 
+# 设置权限 (在 storage:link 完成后设置权限)
+print_message "Setting Folder Permissions..." "设置文件夹权限..."
+set_permissions
+
+# ===============================================
+# 4. 服务和资源构建
+# ===============================================
 # 设置定时任务
 print_message "Enabling Panel schedule tasks..." "开启面板定时任务..."
 set_schedule
 
-# 设置Horizon
-print_message "Setting Horizon daemon..." "设置Horizon守护程序..."
+# 设置 Horizon
+print_message "Setting Horizon daemon..." "设置 Horizon 守护程序..."
 set_horizon
 
+# 配置 Reverb WebSocket 服务
+print_message "Configuring Reverb WebSocket service..." "配置 Reverb WebSocket 服务..."
+configure_reverb
+
+# 构建前端资源
+print_message "Building frontend assets..." "构建前端资源..."
+build_frontend_assets
+
+# ===============================================
+# 5. 最终步骤
+# ===============================================
 # 下载IP数据库文件
-print_message "Downloading IP database files..." "下载IP数据库文件..."
+print_message "Downloading IP database files..." "下载 IP 数据库文件..."
 cd scripts/ && bash download_dbs.sh

+ 16 - 13
package.json

@@ -1,15 +1,18 @@
 {
-    "private": true,
-    "type": "module",
-    "scripts": {
-        "dev": "vite",
-        "build": "vite build"
-    },
-    "devDependencies": {
-        "@shufo/prettier-plugin-blade": "^1.14.1",
-        "axios": "^1.1.2",
-        "laravel-vite-plugin": "^0.7.5",
-        "prettier": "^3.3.3",
-        "vite": "^4.0.0"
-    }
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "format": "prettier --write resources/views/**/*.blade.php"
+  },
+  "devDependencies": {
+    "@shufo/prettier-plugin-blade": "^1.14.1",
+    "axios": "^1.1.2",
+    "laravel-echo": "^2.2.0",
+    "laravel-vite-plugin": "^2.0.1",
+    "prettier": "^3.3.3",
+    "pusher-js": "^8.4.0",
+    "vite": "^7.1.7"
+  }
 }

文件差异内容过多而无法显示
+ 0 - 0
public/assets/global/vendor/toastr/toastr.js.map


+ 62 - 52
public/assets/js/config/common.js

@@ -3,7 +3,9 @@
  */
 
 /* 辅助:替换路由模板中的 PLACEHOLDER */
-const jsRoute = (template, id) => template.replace("PLACEHOLDER", id);
+const jsRoute = (template, id) => template.replace(id ? "PLACEHOLDER" : "/PLACEHOLDER", id || "");
+
+
 
 /* -----------------------
    小工具 / 辅助函数
@@ -43,51 +45,37 @@ function buildErrorHtml(errors) {
  * @param {function} options.complete - 请求完成后回调(无论成功失败)
  */
 function ajaxRequest(options) {
-    const s = {
+    // 简化对象合并
+    const settings = Object.assign({
         method: "GET",
         dataType: "json",
-        data: {},
-        // keep provided callbacks if any
-        beforeSend: undefined,
-        complete: undefined,
-        success: undefined,
-        error: undefined,
-        ...options
-    };
+        data: {}
+    }, options);
 
     // CSRF 自动注入(只在写方法上)
-    if (["POST", "PUT", "DELETE", "PATCH"].includes(s.method.toUpperCase()) &&
+    if (["POST", "PUT", "DELETE", "PATCH"].includes(settings.method.toUpperCase()) &&
         typeof CSRF_TOKEN !== "undefined" &&
-        !(s.data && s.data._token)) {
-        s.data = {...(s.data || {}), _token: CSRF_TOKEN};
+        !(settings.data && settings.data._token)) {
+        settings.data = Object.assign({}, settings.data || {}, { _token: CSRF_TOKEN });
     }
 
     // loading 包装(如果提供 loadingSelector)
-    if (s.loadingSelector) {
-        const origBefore = s.beforeSend;
-        const origComplete = s.complete;
+    if (settings.loadingSelector) {
+        const origBefore = settings.beforeSend;
+        const origComplete = settings.complete;
 
-        s.beforeSend = function (xhr, settings) {
-            try { $(s.loadingSelector).show(); } catch (e) { /* ignore */ }
-            if (typeof origBefore === "function") origBefore.call(this, xhr, settings);
+        settings.beforeSend = function (xhr, opts) {
+            try { $(settings.loadingSelector).show(); } catch (e) { /* ignore */ }
+            if (origBefore) origBefore.call(this, xhr, opts);
         };
 
-        s.complete = function (xhr, status) {
-            try { $(s.loadingSelector).hide(); } catch (e) { /* ignore */ }
-            if (typeof origComplete === "function") origComplete.call(this, xhr, status);
+        settings.complete = function (xhr, status) {
+            try { $(settings.loadingSelector).hide(); } catch (e) { /* ignore */ }
+            if (origComplete) origComplete.call(this, xhr, status);
         };
     }
 
-    return $.ajax({
-        url: s.url,
-        method: s.method,
-        data: s.data,
-        dataType: s.dataType,
-        beforeSend: s.beforeSend,
-        success: s.success,
-        error: s.error,
-        complete: s.complete
-    });
+    return $.ajax(settings);
 }
 
 /**
@@ -159,13 +147,29 @@ function showConfirm(options) {
  * @param {function} options.callback - 关闭后回调
  */
 function showMessage(options = {}) {
+    // 确认按钮显示逻辑:手动设置 > 自动关闭时隐藏 > 默认显示
+    const showConfirmButton = options.showConfirmButton !== undefined 
+        ? options.showConfirmButton 
+        : false;
+
+    const explicitAutoClose = options.autoClose;
+    const hasTimer = options.timer !== undefined;
+    const disableAutoClose = showConfirmButton === true;
+    
+    const isAutoClose = explicitAutoClose !== undefined 
+        ? explicitAutoClose 
+        : (hasTimer ? true : (!disableAutoClose));
+    
+    const timerValue = hasTimer 
+        ? options.timer 
+        : (isAutoClose ? 1500 : null);
+
     const alertOptions = {
         title: options.title || options.message,
         icon: options.icon || "info",
         html: options.html,
-        showConfirmButton: options.showConfirmButton !== undefined ? options.showConfirmButton : !options.autoClose,
-        // 如果没有明确要求显示按钮并且 autoClose 不为 false,则设置默认 timer
-        ...(options.autoClose !== false && options.showConfirmButton !== true && {timer: options.timer || 1500}),
+        showConfirmButton: showConfirmButton,
+        ...(timerValue && isAutoClose && {timer: timerValue}),
         ...(options.title && options.message && !options.html && {text: options.message})
     };
 
@@ -190,7 +194,7 @@ function showMessage(options = {}) {
  * @param {function} options.onError - 自定义错误处理回调
  */
 function handleErrors(xhr, options = {}) {
-    const settings = {validation: 'field', default: 'swal', ...options};
+    const settings = Object.assign({validation: 'field', default: 'swal'}, options);
 
     if (typeof settings.onError === "function") {
         return settings.onError(xhr);
@@ -249,23 +253,30 @@ function handleErrors(xhr, options = {}) {
 
     // 其它错误
     const errorMessage = xhr.responseJSON?.message || xhr.statusText || (typeof TRANS !== "undefined" ? TRANS.request_failed : "Request failed");
-
+    
+    // 提取公共的 showMessage 调用
+    const showMessageOptions = {title: errorMessage, icon: "error"};
+    
     switch (settings.default) {
         case 'element':
-            settings.element && $(settings.element).html(errorMessage).show();
+            if (settings.element) {
+                $(settings.element).html(errorMessage).show();
+            } else {
+                showMessage(showMessageOptions);
+            }
             break;
 
         case 'field':
             if (settings.form) {
-                showMessage({title: errorMessage, icon: "error"});
+                showMessage(showMessageOptions);
             } else {
-                showMessage({title: errorMessage, icon: "error"});
+                showMessage(showMessageOptions);
             }
             break;
 
         case 'swal':
         default:
-            showMessage({title: errorMessage, icon: "error"});
+            showMessage(showMessageOptions);
             break;
     }
 
@@ -288,11 +299,11 @@ function handleErrors(xhr, options = {}) {
  * @returns {Object} 原始响应
  */
 function handleResponse(response, options = {}) {
-    const settings = {reload: true, showMessage: true, ...options};
+    const settings = Object.assign({reload: true, showMessage: true}, options);
 
     if (response?.status === "success") {
         const successCallback = () => {
-            if (typeof settings.onSuccess === "function") {
+            if (settings.onSuccess) {
                 settings.onSuccess(response);
             } else if (settings.redirectUrl) {
                 window.location.href = settings.redirectUrl;
@@ -313,7 +324,7 @@ function handleResponse(response, options = {}) {
         }
     } else {
         const errorCallback = () => {
-            if (typeof settings.onError === "function") settings.onError(response);
+            if (settings.onError) settings.onError(response);
         };
 
         if (settings.showMessage) {
@@ -323,7 +334,7 @@ function handleResponse(response, options = {}) {
                 showConfirmButton: true,
                 callback: errorCallback
             });
-        } else if (typeof settings.onError === "function") {
+        } else if (settings.onError) {
             settings.onError(response);
         }
     }
@@ -359,7 +370,7 @@ function initAutoSubmitSelects(formSelector = "form:not(.modal-body form)", excl
     });
 
     // 仅绑定在指定表单内的 select
-    $(`${formSelector}`).find("select").not(excludeSelector).on("change", function () {
+    $(formSelector).find("select").not(excludeSelector).on("change", function () {
         $(this).closest("form").trigger("submit");
     });
 }
@@ -376,12 +387,11 @@ function initAutoSubmitSelects(formSelector = "form:not(.modal-body form)", excl
  * @returns {boolean} 是否复制成功
  */
 function copyToClipboard(text, options = {}) {
-    const settings = {
+    const settings = Object.assign({
         showMessage: true,
         successMessage: typeof TRANS !== "undefined" ? TRANS.copy.success : "Copy successful",
-        errorMessage: typeof TRANS !== "undefined" ? TRANS.copy.failed : "Copy failed, please copy manually",
-        ...options
-    };
+        errorMessage: typeof TRANS !== "undefined" ? TRANS.copy.failed : "Copy failed, please copy manually"
+    }, options);
 
     if (navigator.clipboard && window.isSecureContext) {
         navigator.clipboard.writeText(text).then(() => {
@@ -454,9 +464,9 @@ function confirmDelete(url, name, attribute, options = {}) {
         icon: options.icon || "warning",
         text: text,
         html: options.html,
-        onConfirm: function () {
+        onConfirm: () => {
             ajaxDelete(url, {}, {
-                success: function (response) {
+                success: (response) => {
                     handleResponse(response, {
                         reload: options.reload !== false,
                         redirectUrl: options.redirectUrl,

+ 0 - 0
resources/css/app.css


+ 24 - 18
resources/js/bootstrap.js

@@ -4,29 +4,35 @@
  * CSRF token as a header based on the value of the "XSRF" token cookie.
  */
 
-import axios from 'axios';
-window.axios = axios;
-
-window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
-
+import Echo from "laravel-echo";
+import axios from "axios";
 /**
  * Echo exposes an expressive API for subscribing to channels and listening
  * for events that are broadcast by Laravel. Echo and event broadcasting
  * allows your team to easily build robust real-time web applications.
  */
+import Pusher from "pusher-js";
+
+window.axios = axios;
+
+window.axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
+
+window.Pusher = Pusher;
 
-// import Echo from 'laravel-echo';
+let forceTLS = window.location.protocol === "https:";
+let options = {
+    broadcaster: "reverb",
+    key: import.meta.env.VITE_REVERB_APP_KEY,
+    wsHost: import.meta.env.VITE_REVERB_HOST || window.location.hostname,
+    forceTLS: forceTLS,
+    enabledTransports: forceTLS ? ["wss"] : ["ws"],
+};
 
-// import Pusher from 'pusher-js';
-// window.Pusher = Pusher;
+let port = import.meta.env.VITE_REVERB_PORT;
+if (forceTLS) {
+    options.wssPort = port || 443;
+} else {
+    options.wsPort = port || 80;
+}
 
-// window.Echo = new Echo({
-//     broadcaster: 'pusher',
-//     key: import.meta.env.VITE_PUSHER_APP_KEY,
-//     cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
-//     wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
-//     wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
-//     wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
-//     forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
-//     enabledTransports: ['ws', 'wss'],
-// });
+window.Echo = new Echo(options);

+ 2 - 1
resources/lang/zh_CN/admin.php

@@ -284,7 +284,6 @@ return [
             'key_placeholder' => '私钥(VNET-V2Ray支持自动签发)',
             'pem_placeholder' => '证书(VNET-V2Ray支持自动签发)',
         ],
-        'connection_test' => '连通性测试',
         'counts' => '共 <code>:num</code> 个节点',
         'info' => [
             'additional_ports_hint' => '需在服务端配置<span class="red-700">additional_ports</span>',
@@ -336,6 +335,8 @@ return [
         'refresh_geo_all' => '刷新全部地理位置',
         'reload' => '重载服务',
         'reload_all' => '重载全部服务',
+        'connection_test' => '连通性测试',
+        'connection_test_all' => '测试全部连通性',
         'reload_confirm' => '确认重载节点服务?',
         'traffic_monitor' => '流量统计',
     ],

+ 1 - 0
resources/lang/zh_CN/common.php

@@ -31,6 +31,7 @@ return [
         'failed' => '复制失败,请手动执行',
         'success' => '复制成功',
     ],
+    'completed_item' => ':attribute完成',
     'create' => '创建',
     'created_at' => '创建日期',
     'customize' => '自定义',

+ 471 - 66
resources/views/admin/node/index.blade.php

@@ -20,16 +20,21 @@
                     <div class="btn-group">
                         @can('admin.node.reload')
                             @if ($nodeList->where('type', 4)->count())
-                                <button class="btn btn-info" type="button" onclick="reload(0)">
+                                <button class="btn btn-info" type="button" onclick="reload()">
                                     <i class="icon wb-reload" id="reload_0" aria-hidden="true"></i> {{ trans('admin.node.reload_all') }}
                                 </button>
                             @endif
                         @endcan
                         @can('admin.node.geo')
-                            <button class="btn btn-outline-default" type="button" onclick="refreshGeo(0)">
+                            <button class="btn btn-outline-default" type="button" onclick="handleNodeAction('geo')">
                                 <i class="icon wb-map" id="geo_0" aria-hidden="true"></i> {{ trans('admin.node.refresh_geo_all') }}
                             </button>
                         @endcan
+                        @can('admin.node.check')
+                            <button class="btn btn-outline-primary" type="button" onclick="handleNodeAction('check')">
+                                <i class="icon wb-signal" id="check_all_nodes" aria-hidden="true"></i> {{ trans('admin.node.connection_test_all') }}
+                            </button>
+                        @endcan
                         @can('admin.node.create')
                             <a class="btn btn-primary" href="{{ route('admin.node.create') }}">
                                 <i class="icon wb-plus"></i> {{ trans('common.add') }}
@@ -105,17 +110,17 @@
                                     @endcan
                                     <hr />
                                     @can('admin.node.geo')
-                                        <x-ui.dropdown-item id="geo{{ $node->id }}" url="javascript:refreshGeo('{{ $node->id }}')" icon="wb-map"
+                                        <x-ui.dropdown-item id="geo_{{ $node->id }}" url="javascript:handleNodeAction('geo', '{{ $node->id }}')" icon="wb-map"
                                                             :text="trans('admin.node.refresh_geo')" />
                                     @endcan
                                     @can('admin.node.check')
-                                        <x-ui.dropdown-item id="node_{{ $node->id }}" url="javascript:checkNode('{{ $node->id }}')" icon="wb-signal"
-                                                            :text="trans('admin.node.connection_test')" />
+                                        <x-ui.dropdown-item id="node_{{ $node->id }}" url="javascript:handleNodeAction('check', '{{ $node->id }}')"
+                                                            icon="wb-signal" :text="trans('admin.node.connection_test')" />
                                     @endcan
                                     @if ($node->type === 4)
                                         @can('admin.node.reload')
                                             <hr />
-                                            <x-ui.dropdown-item id="reload_{{ $node->id }}" url="javascript:reload('{{ $node->id }}')" icon="wb-reload"
+                                            <x-ui.dropdown-item id="reload_{{ $node->id }}" url="javascript:reload({{ $node->id }})" icon="wb-reload"
                                                                 :text="trans('admin.node.reload')" />
                                         @endcan
                                     @endif
@@ -166,12 +171,12 @@
                                         @endcan
                                         <hr />
                                         @can('admin.node.geo')
-                                            <x-ui.dropdown-item id="geo_{{ $childNode->id }}" url="javascript:refreshGeo('{{ $childNode->id }}')" icon="wb-map"
-                                                                :text="trans('admin.node.refresh_geo')" />
+                                            <x-ui.dropdown-item id="geo_{{ $childNode->id }}" url="javascript:handleNodeAction('geo', '{{ $childNode->id }}')"
+                                                                icon="wb-map" :text="trans('admin.node.refresh_geo')" />
                                         @endcan
                                         @can('admin.node.check')
-                                            <x-ui.dropdown-item id="node_{{ $childNode->id }}" url="javascript:checkNode('{{ $childNode->id }}')" icon="wb-signal"
-                                                                :text="trans('admin.node.connection_test')" />
+                                            <x-ui.dropdown-item id="node_{{ $childNode->id }}" url="javascript:handleNodeAction('check', '{{ $childNode->id }}')"
+                                                                icon="wb-signal" :text="trans('admin.node.connection_test')" />
                                         @endcan
                                     </x-ui.dropdown>
                                 @endcan
@@ -182,75 +187,475 @@
             </x-slot:tbody>
         </x-admin.table-panel>
     </div>
+
+    <!-- 节点检测结果模态框 -->
+    <x-ui.modal id="nodeCheckModal" :title="trans('admin.node.connection_test')" size="lg">
+    </x-ui.modal>
+
+    <!-- 节点刷新地理位置结果模态框 -->
+    <x-ui.modal id="nodeGeoRefreshModal" :title="trans('admin.node.refresh_geo')" size="lg">
+    </x-ui.modal>
+
+    <!-- 节点重载结果模态框 -->
+    <x-ui.modal id="nodeReloadModal" :title="trans('admin.node.reload')" size="lg">
+    </x-ui.modal>
 @endsection
 @push('javascript')
+    @vite(['resources/js/app.js'])
     <script>
-        @can('admin.node.check')
-            function checkNode(id) { // 节点连通性测试
-                const $element = $(`#node_${id}`);
-
-                ajaxPost(jsRoute('{{ route('admin.node.check', 'PLACEHOLDER') }}', id), {}, {
-                    beforeSend: function() {
-                        $element.removeClass("wb-signal").addClass("wb-loop icon-spin");
-                    },
-                    success: function(ret) {
-                        if (ret.status === "success") {
-                            let str = "";
-                            for (let i in ret.message) {
-                                str += "<tr><td>" + i + "</td><td>" + ret.message[i][0] + "</td><td>" + ret.message[i][1] +
-                                    "</td></tr>";
-                            }
-                            showMessage({
-                                title: ret.title,
-                                html: "<table class=\"my-20\"><thead class=\"thead-default\"><tr><th> IP </th><th> ICMP </th> <th> TCP </th></thead><tbody>" +
-                                    str + "</tbody></table>",
-                                autoClose: false
-                            });
-                        } else {
-                            showMessage({
-                                title: ret.title,
-                                message: ret.message,
-                                icon: "error"
-                            });
-                        }
-                    },
-                    complete: function() {
-                        $element.removeClass("wb-loop icon-spin").addClass("wb-signal");
+        // 全局状态
+        const state = {
+            actionType: null, // 'check' | 'geo' | 'reload'
+            actionId: null, // 当前操作针对的节点 id(null/'' 表示批量)
+            channel: null,
+            results: {}, // 按 nodeId 存储节点信息与已收到的数据
+            finished: {}, // 标记 nodeId 是否完成
+            spinnerFallbacks: {}, // 防止无限 spinner 的后备定时器
+            errorDisplayed: false
+        };
+
+        const networkStatus = @json(trans('admin.network_status'));
+
+        // Reverb 简化管理(保留必须的健壮性)
+        const Reverb = {
+            get conn() {
+                return Echo?.connector?.pusher?.connection || Echo?.connector?.socket || null;
+            },
+            isConnected() {
+                const c = this.conn;
+                if (!c) return false;
+                const s = c.state?.current ?? c.readyState;
+                return s === 'connected' || s === 'open' || s === 1;
+            },
+            handleError(msg) {
+                if (!state.errorDisplayed && !this.isConnected()) {
+                    showMessage({
+                        title: '{{ trans('common.error') }}',
+                        message: msg,
+                        icon: 'error',
+                        showConfirmButton: true
+                    });
+                    state.errorDisplayed = true;
+                }
+            },
+            clearError() {
+                state.errorDisplayed = false;
+            },
+            cleanupChannel() {
+                if (state.channel) {
+                    try {
+                        state.channel.stopListening('.node.check.result');
+                        state.channel.stopListening('.node.geo.refresh.result');
+                        state.channel.stopListening('.node.reload.result');
+                    } catch (e) {
+                        /* ignore */
                     }
-                });
+                    state.channel = null;
+                }
+            },
+            setup(channelName, type, eventName, handler) {
+                // 只在真正需要切换时 cleanup(外层调用已控制)
+                if (!this.conn) {
+                    this.handleError('WebSocket is not available. Please make sure the Reverb server is running and properly configured.');
+                    return false;
+                }
+
+                try {
+                    // 订阅频道并监听事件
+                    state.channel = Echo.channel(channelName);
+                    state.channel.listen(eventName, handler);
+                    // 连接事件绑定:连接成功后清除 error 标记
+                    const c = this.conn;
+                    if (c?.bind) {
+                        c.bind && c.bind('connected', () => this.clearError());
+                        c.bind && c.bind('disconnected', () => this.handleError('WebSocket connection lost.'));
+                    }
+                    return true;
+                } catch (e) {
+                    // 只有在确实不是已连接时才提示
+                    if (!this.isConnected()) {
+                        this.handleError('Broadcasting is not set-up or connection failed. Error: ' + (e && e.message || e));
+                        return false;
+                    }
+                    return true;
+                }
             }
-        @endcan
+        };
 
-        @can('admin.node.reload')
-            function reload(id) { // 发送节点重载请求
-                const $element = $(`#reload_${id}`);
+        // 配置表:保留原按钮 id 规则 & 原模态结构
+        const ACTION_CFG = {
+            check: {
+                icon: 'wb-signal',
+                routeTpl: '{{ route('admin.node.check', 'PLACEHOLDER') }}',
+                event: '.node.actions',
+                modal: '#nodeCheckModal',
+                btnSelector: (id) => id ? $(`#node_${id}`) : $('#check_all_nodes'),
+                buildUI: buildCheckUI,
+                updateUI: updateCheckUI,
+                isNodeDone: function(node) {
+                    // node.ips 是 array,node.data 是按 ip 存放结果
+                    if (!Array.isArray(node.ips)) return !!node.data; // 没有 ip 列表的认为收到数据就算
+                    const got = Object.keys(node.data || {}).length;
+                    return got >= node.ips.length;
+                },
+                successMsg: '{{ trans('common.completed_item', ['attribute' => trans('admin.node.connection_test')]) }}'
+            },
+            geo: {
+                icon: 'wb-map',
+                routeTpl: '{{ route('admin.node.geo', 'PLACEHOLDER') }}',
+                event: '.node.actions',
+                modal: '#nodeGeoRefreshModal',
+                btnSelector: (id) => id ? $(`#geo_${id}`) : $('#geo_0'),
+                buildUI: buildGeoUI,
+                updateUI: updateGeoUI,
+                isNodeDone: function(node) {
+                    return !!(node.data && Object.keys(node.data).length > 0);
+                },
+                successMsg: '{{ trans('common.completed_item', ['attribute' => trans('admin.node.refresh_geo')]) }}'
+            },
+            reload: {
+                icon: 'wb-reload',
+                routeTpl: '{{ route('admin.node.reload', 'PLACEHOLDER') }}',
+                event: '.node.actions',
+                modal: '#nodeReloadModal',
+                btnSelector: (id) => id ? $(`#reload_${id}`) : $(`#reload_0`),
+                buildUI: buildReloadUI,
+                updateUI: updateReloadUI,
+                isNodeDone: function(node) {
+                    // 重载有 list 或 error 认为完成
+                    return !!(node.data && (Array.isArray(node.data.list) || Array.isArray(node.data.error) || node.data.list || node.data.error));
+                },
+                successMsg: '{{ trans('common.completed_item', ['attribute' => trans('admin.node.reload')]) }}'
+            }
+        };
 
-                showConfirm({
-                    text: '{{ trans('admin.node.reload_confirm') }}',
-                    onConfirm: function() {
-                        ajaxPost(jsRoute('{{ route('admin.node.reload', 'PLACEHOLDER') }}', id), {}, {
-                            beforeSend: function() {
-                                $element.removeClass("wb-reload").addClass("wb-loop icon-spin");
-                            },
-                            complete: function() {
-                                $element.removeClass("wb-loop icon-spin").addClass("wb-reload");
-                            }
+        // 清理(仅用于开始新操作时)
+        function cleanupPreviousConnection() {
+            state.results = {};
+            state.finished = {};
+            state.actionType = null;
+            state.actionId = null;
+            Reverb.cleanupChannel?.();
+            // 不清理模态内容,这样用户关闭/打开 modal 不会影响正在进行的内容
+        }
+
+        // 统一设置 spinner(显示/隐藏)
+        function setSpinner($el, iconClass, on) {
+            if (!$el || !$el.length) return;
+            if (on) {
+                $el.removeClass(iconClass).addClass('wb-loop icon-spin');
+            } else {
+                $el.removeClass('wb-loop icon-spin').addClass(iconClass);
+            }
+        }
+
+        // 启动后备定时器(防止 spinner 卡住)
+        function startSpinnerFallback(key, $el, iconClass) {
+            clearSpinnerFallback(key);
+            state.spinnerFallbacks[key] = setTimeout(() => {
+                setSpinner($el, iconClass, false);
+                toastr.warning('{{ trans('A Timeout Occurred') }}');
+                delete state.spinnerFallbacks[key];
+            }, 120000); // 2 分钟兜底
+        }
+
+        function clearSpinnerFallback(key) {
+            if (state.spinnerFallbacks[key]) {
+                clearTimeout(state.spinnerFallbacks[key]);
+                delete state.spinnerFallbacks[key];
+            }
+        }
+
+        // 通用操作入口
+        function handleNodeAction(type, id) {
+            const cfg = ACTION_CFG[type];
+            if (!cfg) return;
+
+            const $btn = cfg.btnSelector(id);
+            const channelName = id ? `node.${type}.${id}` : `node.${type}.all`;
+            const routeTpl = cfg.routeTpl;
+
+            // 如果相同操作正在进行并且已有结果缓存,则仅打开 modal(不重复发起)
+            if (state.actionType === type && String(state.actionId) === String(id) && Object.keys(state.results).length > 0) {
+                $(cfg.modal).modal('show');
+                return;
+            }
+
+            // 开始新操作:清理之前的连接/缓存(这是你希望的行为)
+            cleanupPreviousConnection();
+            state.actionType = type;
+            state.actionId = id;
+            state.results = {};
+            state.finished = {};
+
+            // 启动 spinner(保持加载直到我们检测到完成)
+            setSpinner($btn, cfg.icon, true);
+            // 启动后备定时器
+            const fallbackKey = `${type}_${id ?? 'all'}`;
+            startSpinnerFallback(fallbackKey, $btn, cfg.icon);
+
+            // 订阅广播事件
+            const ok = Reverb.setup(channelName, type, cfg.event, (e) => handleResult(e.data || e, type, id, $btn));
+            if (!ok) {
+                // 订阅失败:恢复按钮状态
+                setSpinner($btn, cfg.icon, false);
+                clearSpinnerFallback(fallbackKey);
+                return;
+            }
+
+            // 触发后端接口(Ajax)
+            ajaxPost(jsRoute(routeTpl, id), {}, {
+                beforeSend: function() {
+                    // spinner 已经设置
+                },
+                success: function(ret) {
+                    // 不在此处处理最终结果,交由广播处理(避免 race)
+                },
+                error: function(xhr, status, error) {
+                    if (!Reverb.isConnected()) {
+                        Reverb.handleError('WebSocket is not available. Please make sure the Reverb server is running.');
+                    } else {
+                        showMessage({
+                            title: '{{ trans('common.error') }}',
+                            message: `{{ trans('common.request_failed') }} ${error}: ${xhr?.responseJSON?.exception}`,
+                            icon: 'error',
+                            showConfirmButton: true
                         });
                     }
+                    // 出错时恢复 spinner
+                    setSpinner($btn, cfg.icon, false);
+                    clearSpinnerFallback(fallbackKey);
+                }
+            });
+        }
+
+        // 处理广播数据的统一入口
+        function handleResult(e, type, id, $btn) {
+            const cfg = ACTION_CFG[type];
+            if (!cfg) return;
+
+            // 如果包含 nodeList:构建初始 UI 框架
+            if (e.nodeList) {
+                Object.keys(e.nodeList).forEach(nodeId => {
+                    const nodeInfo = e.nodeList[nodeId];
+                    state.results[nodeId] = {
+                        name: (typeof nodeInfo === 'string') ? nodeInfo : (nodeInfo.name || ''),
+                        ips: (nodeInfo.ips && Array.isArray(nodeInfo.ips)) ? nodeInfo.ips : (nodeInfo.ips || []),
+                        data: {}
+                    };
                 });
+                // 构建并显示 modal
+                cfg.buildUI();
+                return;
             }
-        @endcan
 
-        @can('admin.node.geo')
-            function refreshGeo(id) { // 刷新节点地理信息
-                const $element = $(`#geo_${id}`);
+            // 处理详细数据
+            try {
+                const nodeId = e.nodeId;
+                if (!nodeId || !state.results[nodeId]) return;
+
+                if (type === 'check' && (e.icmp !== undefined || e.tcp !== undefined)) {
+                    if (!state.results[nodeId].data[e.ip]) {
+                        state.results[nodeId].data[e.ip] = {};
+                    }
+                    state.results[nodeId].data[e.ip] = {
+                        icmp: e.icmp,
+                        tcp: e.tcp
+                    };
+                    cfg.updateUI(nodeId, e);
+                } else if (type === 'geo' && e) {
+                    state.results[nodeId].data = e;
+                    cfg.updateUI(nodeId, e);
+                } else if (type === 'reload' && e) {
+                    state.results[nodeId].data = e;
+                    cfg.updateUI(nodeId, e);
+                }
+
+                // 检查是否所有节点都完成
+                const allDone = Object.keys(state.results).length > 0 &&
+                    Object.keys(state.results).every(nodeId => cfg.isNodeDone(state.results[nodeId]));
+
+                if (allDone) {
+                    const fallbackKey = `${type}_${id ?? 'all'}`;
+                    setSpinner($btn, cfg.icon, false);
+                    clearSpinnerFallback(fallbackKey);
+                    toastr.success(cfg.successMsg);
+                }
+            } catch (err) {
+                console.error('handleResult error', err);
+            }
+        }
+
+        // check UI
+        function buildCheckUI() {
+            $('#nodeCheckModal').modal('show');
+            const body = document.querySelector('#nodeCheckModal .modal-body');
+            let html = '<div class="row">';
+            const nodeIds = Object.keys(state.results);
+            const columnClass = nodeIds.length > 1 ? 'col-md-6' : 'col-12';
+
+            nodeIds.forEach(nodeId => {
+                const node = state.results[nodeId];
+                html += `
+                    <div class="${columnClass}" data-node-id="${nodeId}">
+                        <h5>${node.name}</h5>
+                        <table class="table table-hover">
+                            <thead>
+                                <tr>
+                                    <th>{{ trans('user.attribute.ip') }}</th>
+                                    <th>ICMP</th>
+                                    <th>TCP</th>
+                                </tr>
+                            </thead>
+                            <tbody>`;
+                if (Array.isArray(node.ips)) {
+                    node.ips.forEach(ip => {
+                        html += `
+                            <tr data-ip="${ip}">
+                                <td>${ip}</td>
+                                <td><i class="wb-loop icon-spin"></i></td>
+                                <td><i class="wb-loop icon-spin"></i></td>
+                            </tr>`;
+                    });
+                }
+                html += `</tbody></table></div>`;
+            });
+            html += '</div>';
+            body.innerHTML = html;
+        }
+
+        function updateCheckUI(nodeId, data) {
+            try {
+                // 使用 data-* 属性选择器定位元素
+                const row = document.querySelector(`#nodeCheckModal div[data-node-id="${nodeId}"] tr[data-ip="${data.ip}"]`);
+                if (!row) return;
+
+                // 使用 nth-child 选择器定位 td 元素
+                const icmpEl = row.querySelector('td:nth-child(2)');
+                const tcpEl = row.querySelector('td:nth-child(3)');
+
+                if (icmpEl) icmpEl.innerHTML = networkStatus[data.icmp] || networkStatus[4];
+                if (tcpEl) tcpEl.innerHTML = networkStatus[data.tcp] || networkStatus[4];
+            } catch (e) {}
+        }
+
+        // geo UI
+        function buildGeoUI() {
+            $('#nodeGeoRefreshModal').modal('show');
+            const body = document.querySelector('#nodeGeoRefreshModal .modal-body');
+            let html = `<table class="table table-hover">
+                            <thead>
+                                <tr>
+                                    <th>{{ trans('validation.attributes.name') }}</th>
+                                    <th>{{ trans('common.status.attribute') }}</th>
+                                    <th>{{ trans('validation.attributes.message') }}</th>
+                                </tr>
+                            </thead>
+                            <tbody>`;
+
+            Object.keys(state.results).forEach(nodeId => {
+                const node = state.results[nodeId];
+                html += `
+                    <tr data-node-id="${nodeId}">
+                        <td>${node.name}</td>
+                        <td><i class="wb-loop icon-spin"></i></td>
+                        <td><i class="wb-loop icon-spin"></i></td>
+                    </tr>`;
+            });
+            html += '</tbody></table></div>';
+            body.innerHTML = html;
+        }
+
+        function updateGeoUI(nodeId, data) {
+            try {
+                const row = document.querySelector(`#nodeGeoRefreshModal tr[data-node-id="${nodeId}"]`);
+                if (!row) return;
+
+                const statusEl = row.querySelector('td:nth-child(2)');
+                const infoEl = row.querySelector('td:nth-child(3)');
+                if (!statusEl || !infoEl) return;
+
+                let status = '❌';
+                let info = data.error || '-';
 
-                ajaxGet(jsRoute('{{ route('admin.node.geo', 'PLACEHOLDER') }}', id), {}, {
-                    beforeSend: function() {
-                        $element.removeClass("wb-map").addClass("wb-loop icon-spin");
-                    },
-                    complete: function() {
-                        $element.removeClass("wb-loop icon-spin").addClass("wb-map");
+                if (!data.error && Array.isArray(data.original) && Array.isArray(data.update)) {
+                    const filteredOriginal = data.original.filter(v => v !== null);
+                    const filteredUpdate = data.update.filter(v => v !== null);
+                    const isSame = filteredOriginal.length === filteredUpdate.length &&
+                        filteredOriginal.every((val, idx) => {
+                            const n1 = typeof val === 'number' ? val : parseFloat(val);
+                            const n2 = typeof filteredUpdate[idx] === 'number' ? filteredUpdate[idx] : parseFloat(filteredUpdate[idx]);
+                            if (!isNaN(n1) && !isNaN(n2)) return Math.abs(n1 - n2) < 1e-2;
+                            return val === filteredUpdate[idx];
+                        });
+                    status = '✔️';
+                    info = isSame ? '{{ trans('Not Modified') }}' :
+                        `{{ trans('common.update') }}: [${filteredOriginal.join(', ') || '-'}] => [${filteredUpdate.join(', ') || '-'}]`;
+                }
+
+                statusEl.innerHTML = status;
+                infoEl.innerHTML = info;
+            } catch (e) {}
+        }
+
+        // reload UI
+        function buildReloadUI() {
+            $('#nodeReloadModal').modal('show');
+            const body = document.querySelector('#nodeReloadModal .modal-body');
+            let html = `<table class="table table-hover">
+                    <thead>
+                        <tr>
+                            <th>{{ trans('validation.attributes.name') }}</th>
+                            <th>{{ trans('common.status.attribute') }}</th>
+                            <th>{{ trans('validation.attributes.message') }}</th>
+                        </tr>
+                    </thead>
+                    <tbody>`;
+
+            Object.keys(state.results).forEach(nodeId => {
+                const node = state.results[nodeId];
+                html += `<tr data-node-id="${nodeId}">
+                <td>${node.name}</td>
+                <td><i class="wb-loop icon-spin"></i></td>
+                <td><i class="wb-loop icon-spin"></i></td>
+            </tr>`;
+            });
+            html += '</tbody></table>';
+            body.innerHTML = html;
+        }
+
+        function updateReloadUI(nodeId, data) {
+            try {
+                const row = document.querySelector(`#nodeReloadModal tr[data-node-id="${nodeId}"]`);
+                if (!row) return;
+
+                const statusEl = row.querySelector('td:nth-child(2)');
+                const infoEl = row.querySelector('td:nth-child(3)');
+
+                if (!statusEl || !infoEl) return;
+
+                // 处理状态显示
+                let status = '❌'; // 默认失败状态
+                let info = '';
+
+                if (!data.error || (Array.isArray(data.error) && data.error.length === 0)) {
+                    status = '✔️';
+                } else if (Array.isArray(data.error) && data.error.length > 0) {
+                    // 有错误信息
+                    info = `{{ trans('common.error') }}: ${data.error.join(', ')}`;
+                }
+
+                statusEl.innerHTML = status;
+                infoEl.innerHTML = info;
+            } catch (e) {}
+        }
+
+        @can('admin.node.reload')
+            function reload(id = null) {
+                showConfirm({
+                    text: '{{ trans('admin.node.reload_confirm') }}',
+                    onConfirm: function() {
+                        handleNodeAction('reload', id);
                     }
                 });
             }

+ 3 - 0
resources/views/admin/table_layouts.blade.php

@@ -2,6 +2,7 @@
 @section('css')
     <link href="/assets/global/vendor/bootstrap-table/bootstrap-table.min.css" rel="stylesheet">
     <link href="/assets/global/vendor/bootstrap-select/bootstrap-select.min.css" rel="stylesheet">
+    <link href="/assets/global/vendor/toastr/toastr.min.css" rel="stylesheet">
     <style>
         #swal2-content {
             display: grid !important;
@@ -21,7 +22,9 @@
     <script src="/assets/global/vendor/bootstrap-table/bootstrap-table.min.js"></script>
     <script src="/assets/global/vendor/bootstrap-table/extensions/mobile/bootstrap-table-mobile.min.js"></script>
     <script src="/assets/global/vendor/bootstrap-select/bootstrap-select.min.js"></script>
+    <script src="/assets/global/vendor/toastr/toastr.min.js"></script>
     <script src="/assets/global/js/Plugin/bootstrap-select.js"></script>
+    <script src="/assets/global/js/Plugin/toastr.js"></script>
     <script>
         $("form:not(.modal-body form)").on("submit", function() {
             $(this).find("input:not([type=\"submit\"]), select").filter(function() {

+ 3 - 3
resources/views/components/ui/modal.blade.php

@@ -1,7 +1,7 @@
 @props([
     'id',
     'title' => null,
-    'size' => 'simple', // simple, lg, sm, etc.
+    'size' => null, // lg, sm, etc.
     'position' => 'center', // center, sidebar, etc.
     'labelledby' => null,
     'backdrop' => true,
@@ -13,8 +13,8 @@
 <div class="modal fade" id="{{ $id }}" role="dialog" aria-hidden="true" aria-labelledby="{{ $labelledby ?? $id }}" tabindex="-1"
      @if (!$backdrop) data-backdrop="static" @endif @if (!$keyboard) data-keyboard="false" @endif
      @if ($focus) data-focus-on="input:first" @endif>
-    <div class="modal-dialog modal-{{ $size }} modal-{{ $position }}">
-        <div class="modal-content">
+    <div class="modal-dialog modal-simple @if ($size) modal-{{ $size }} @endif modal-{{ $position }}">
+        <div class="modal-content" style="max-height: 80vh; overflow: auto;">
             @if ($title || isset($header))
                 <div class="modal-header">
                     <button class="close" data-dismiss="modal" type="button" aria-label="{{ trans('common.close') }}">

+ 83 - 39
resources/views/user/components/payment/default.blade.php

@@ -38,59 +38,103 @@
     </div>
 @endsection
 @section('javascript')
+    @vite(['resources/js/app.js'])
     @if ($payment->qr_code && $payment->url)
         <script src="/assets/custom/easy.qrcode.min.js"></script>
         <script>
-            // Options
-            const options = {
+            // Create QRCode Object
+            new QRCode(document.getElementById("qrcode"), {
                 text: @json($payment->url),
                 backgroundImage: '{{ asset($pay_type_icon) }}',
                 autoColor: true
-            };
-
-            // Create QRCode Object
-            new QRCode(document.getElementById("qrcode"), options);
+            });
         </script>
     @endif
 
     <script>
-        // 检查支付单状态
-        let pollingInterval = window.setInterval(function() {
-            ajaxGet('{{ route('orderStatus') }}', {
-                trade_no: '{{ $payment->trade_no }}'
-            }, {
-                success: function(ret) {
-                    if (ret.status === "success" || ret.status === "error") {
-                        window.clearInterval(pollingInterval);
+        @if (config('broadcasting.default') !== 'null')
+            let pollingStarted = false
+            let pollingInterval = null
+
+            function clearAll() {
+                if (pollingInterval) {
+                    clearInterval(pollingInterval);
+                    pollingInterval = null
+                }
+            }
 
-                        if (ret.status === "success") {
-                            showMessage({
-                                title: ret.message,
-                                icon: "success",
-                                showConfirmButton: false,
-                                callback: function() {
-                                    window.location.href = '{{ route('invoice.index') }}';
-                                }
-                            });
-                        } else if (ret.status === "error") {
-                            showMessage({
-                                title: ret.message,
-                                icon: "error",
-                                showConfirmButton: false,
-                                callback: function() {
-                                    window.location.href = '{{ route('invoice.index') }}';
-                                }
-                            });
-                        }
+            function disconnectEcho() {
+                try {
+                    if (typeof Echo !== 'undefined') {
+                        Echo.leave(`payment-status.{{ $payment->trade_no }}`)
+                        Echo.connector?.disconnect?.()
                     }
+                } catch (e) {
+                    console.error('关闭 Echo 失败:', e)
                 }
-            });
-        }, 3000);
+            }
+
+            function onFinal(status, message) {
+                clearAll()
+                disconnectEcho()
+                showMessage({
+                    title: message,
+                    icon: status === 'success' ? 'success' : 'error',
+                    showConfirmButton: false,
+                    callback: () => window.location.href = '{{ route('invoice.index') }}'
+                })
+            }
+
+            function startPolling() {
+                if (pollingStarted) return
+                pollingStarted = true
+                disconnectEcho()
+
+                pollingInterval = setInterval(() => {
+                    ajaxGet('{{ route('orderStatus') }}', {
+                        trade_no: '{{ $payment->trade_no }}'
+                    }, {
+                        success: ret => {
+                            if (['success', 'error'].includes(ret.status)) {
+                                onFinal(ret.status, ret.message)
+                            }
+                        },
+                        error: () => onFinal('error', "{{ trans('common.request_failed') }}")
+                    })
+                }, 3000)
+            }
+
+            function setupPaymentListener() {
+                if (typeof Echo === 'undefined' || typeof Pusher === 'undefined') {
+                    startPolling()
+                    return
+                }
+                try {
+                    const conn = Echo.connector?.pusher?.connection || Echo.connector?.socket
+                    if (conn) {
+                        conn.bind?.('state_change', s => {
+                            if (['disconnected', 'failed', 'unavailable'].includes(s.current)) startPolling()
+                        })
+                        conn.on?.('disconnect', () => startPolling())
+                        conn.on?.('error', () => startPolling())
+                    }
+
+                    Echo.channel('payment-status.{{ $payment->trade_no }}')
+                        .listen('.payment.status.updated', (e) => {
+                            if (['success', 'error'].includes(e.status)) {
+                                onFinal(e.status, e.message)
+                            }
+                        })
 
-        window.addEventListener('beforeunload', function() {
-            if (pollingInterval) {
-                window.clearInterval(pollingInterval);
+                } catch (e) {
+                    console.error('Echo 初始化失败:', e)
+                    startPolling()
+                }
             }
-        });
+
+            window.addEventListener('load', setupPaymentListener)
+        @else
+            startPolling()
+        @endif
     </script>
 @endsection

+ 3 - 3
routes/admin.php

@@ -67,9 +67,9 @@ Route::prefix('admin')->name('admin.')->group(function () {
     Route::prefix('node')->name('node.')->controller(NodeController::class)->group(function () {
         Route::get('clone/{node}', 'clone')->name('clone'); // 节点流量监控
         Route::get('monitor/{node}', 'nodeMonitor')->name('monitor'); // 节点流量监控
-        Route::post('check/{node}', 'checkNode')->name('check'); // 节点阻断检测
-        Route::get('refreshGeo/{id}', 'refreshGeo')->name('geo'); // 更新节点
-        Route::post('reload/{id}', 'reload')->name('reload'); // 更新节点
+        Route::post('check/{node?}', 'checkNode')->name('check'); // 节点阻断检测
+        Route::post('refreshGeo/{node?}', 'refreshGeo')->name('geo'); // 更新节点地理位置
+        Route::post('reload/{node?}', 'reload')->name('reload'); // 重载节点
         Route::resource('auth', NodeAuthController::class)->except(['create', 'show', 'edit']); // 节点授权相关
         Route::resource('cert', CertController::class)->except('show'); // 节点域名tls相关
     });

+ 19 - 2
routes/channels.php

@@ -1,5 +1,7 @@
 <?php
 
+use App\Models\Node;
+use App\Models\Payment;
 use Illuminate\Support\Facades\Broadcast;
 
 /*
@@ -13,6 +15,21 @@ use Illuminate\Support\Facades\Broadcast;
 |
 */
 
-Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
-    return (int) $user->id === (int) $id;
+Broadcast::channel('payment-status.{tradeNo}', static function ($user, $tradeNo) {
+    // 检查订单是否属于该用户
+    return $user->id === Payment::whereTradeNo($tradeNo)->first()?->user->id;
+});
+
+Broadcast::channel('node.{type}.{nodeId}', static function ($user, $type, $nodeId) {
+    // 验证用户权限和节点访问权限
+    if (! $user->can("admin.node.$type")) {
+        return false;
+    }
+
+    // 如果是特定节点操作,验证节点存在性和访问权限
+    if ($nodeId !== 'all') {
+        return Node::where('id', $nodeId)->exists();
+    }
+
+    return true;
 });

+ 325 - 127
scripts/lib.sh

@@ -1,176 +1,374 @@
 #!/bin/bash
 
-# 定义输出函数
-function print_message() {
-    echo -e "\e[34m========= $1 | $2 =========\e[0m"
-}
+# ===============================================
+# 1. 基础输出函数
+# ===============================================
 
-function print_logo() {
-cat << "EOF"
-   ___                              ___                      _
-  / _ \ _ __   ___  __  __ _   _   / _ \  __ _  _ __    ___ | |
- / /_)/| '__| / _ \ \ \/ /| | | | / /_)/ / _` || '_ \  / _ \| |
-/ ___/ | |   | (_) | >  < | |_| |/ ___/ | (_| || | | ||  __/| |
-\/     |_|    \___/ /_/\_\ \__, |\/      \__,_||_| |_| \___||_|
-                           |___/
+print_logo() {
+    cat << "EOF"
+    ___                              ___                      _
+   / _ \ _ __   ___  __  __ _   _   / _ \  __ _  _ __    ___ | |
+  / /_)/| '__| / _ \ \ \/ /| | | | / /_)/ / _` || '_ \  / _ \| |
+ / ___/ | |   | (_) | >  < | |_| |/ ___/ | (_| || | | ||  __/| |
+ \/     |_|    \___/ /_/\_\ \__, |\/      \__,_||_| |_| \___||_|
+                            |___/
 
 EOF
 }
 
-# 安装依赖
-install_dependencies() {
-  # 判断系统
-  if [[ -f /etc/debian_version ]]; then
-    PM=apt-get
-  elif [[ -f /etc/redhat-release ]]; then
-    PM=yum
-  elif [[ -f /etc/SuSE-release ]]; then
-    PM=zypper
-  elif [[ -f /etc/arch-release ]]; then
-    PM=pacman
-  elif [[ -f /etc/alpine-release ]]; then
-    PM=apk
-  else
-    echo -e "\e[31m不支持的Linux发行版。\e[0m"
-    exit 1
-  fi
-
-  if command -v supervisorctl >/dev/null; then
-    echo -e "\e[32mSupervisor installed! | Supervisor 已完成!\e[0m"
-  else
-    echo -e "\e[31mSupervisor did not installed! | Supervisor 未安装!\e[0m"
-    # 安装 Supervisor
-    case $PM in
-    apt-get)
-      sudo apt-get update
-      sudo apt-get install -y supervisor
-      ;;
-    yum)
-      sudo yum install -y epel-release
-      sudo yum install -y supervisor
-      ;;
-    zypper)
-      sudo zypper install -y supervisor
-      ;;
-    apk)
-      sudo apk add supervisor
-      ;;
-    pacman)
-      sudo pacman -S supervisor
-      ;;
-    esac
-
-    # 激活
-    case $PM in
-    yum)
-      sudo service supervisord start
-      sudo chkconfig supervisord on
-      ;;
-    *)
-      sudo systemctl start supervisor.service
-      sudo systemctl enable supervisor.service
-      ;;
-    esac
-    echo -e "\e[32mSupervisor installation completed! | Supervisor 安装完成!\e[0m"
-  fi
+print_message() {
+    # 格式: 绿色中文 | 英文
+    echo -e "\033[32m$2\033[0m | $1"
 }
 
-# 清理不需要的文件
-clean_files() {
-  rm -rf .htaccess 404.html index.html
-  if [ -f .user.ini ]; then
-    chattr -i .user.ini
-    rm -f .user.ini
-  fi
-}
+# ===============================================
+# 2. 环境检查函数
+# ===============================================
 
 # 检查软件是否安装
 check_available() {
   tools=$1
   if command -v "$tools" >/dev/null 2>&1; then
-    echo -e "\e[32m$tools Installed! | $tools 已安装!\e[0m"
+    print_message "$tools Installed!" "$tools 已安装!"
   else
-    echo -e "\e[31m$tools did not installed! | $tools 未安装!\e[0m"
+    print_message "$tools did not installed!" "$tools 未安装!"
   fi
 }
 
-# 检查环境
-check_env() {
+# 检查 Web 环境
+check_web_environment() {
+  print_message "Checking web environment..." "正在检查 Web 环境..."
   check_available php
   check_available php-fpm
   check_available nginx
+  check_available apache2
   check_available mysql
   check_available redis-cli
 }
 
-# 检查composer是否安装
-check_composer() {
-  if [ ! -f "/usr/bin/composer" ]; then
-    curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
-  else
-    if [[ $(composer -n --version --no-ansi 2>/dev/null | cut -d" " -f3) < 2.2.0 ]]; then
-      composer self-update
+# ===============================================
+# 3. 依赖安装函数
+# ===============================================
+
+install_composer() {
+    # 检查是否安装了 Composer
+    if ! command -v composer &>/dev/null; then
+        print_message "Composer not found, installing..." "未找到 Composer,正在安装..."
+        EXPECTED_CHECKSUM="$(php -r 'copy("https://composer.github.io/installer.sig", "php://stdout");')"
+        php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
+        ACTUAL_CHECKSUM="$(php -r "echo hash_file('sha384', 'composer-setup.php');")"
+
+        if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
+            >&2 print_message "ERROR: Invalid installer checksum" "错误: 安装程序校验和无效"
+            rm composer-setup.php
+            exit 1
+        fi
+
+        php composer-setup.php --quiet
+        rm composer-setup.php
+        mv composer.phar /usr/local/bin/composer
+    else
+        print_message "Composer is already installed." "Composer 已安装。"
+        # 导入 lib_o.sh 的 composer self-update 逻辑
+        if [[ $(composer -n --version --no-ansi 2>/dev/null | cut -d" " -f3) < 2.2.0 ]]; then
+            print_message "Updating Composer..." "正在更新 Composer..."
+            composer self-update
+        fi
     fi
-  fi
 }
 
-# 设置权限
+# 安装 Node.js 和 NPM (新增函数)
+install_nodejs_npm() {
+    if command -v node &>/dev/null && command -v npm &>/dev/null; then
+        print_message "Node.js and npm are already installed." "Node.js 和 npm 已安装。"
+        return 0
+    fi
+
+    print_message "Node.js or npm not found, attempting installation..." "未找到 Node.js 或 npm,正在尝试安装..."
+
+    # Node.js LTS 版本
+    NODE_VERSION=22
+
+    if [[ -f /etc/debian_version ]]; then
+        # Debian/Ubuntu (使用 NodeSource 仓库获取 LTS 版本)
+        print_message "Using NodeSource repository for Debian/Ubuntu." "正在使用 NodeSource 仓库 (Debian/Ubuntu)。"
+        curl -fsSL https://deb.nodesource.com/setup_$NODE_VERSION.x | sudo -E bash -
+        sudo apt-get install -y nodejs
+    elif [[ -f /etc/redhat-release ]]; then
+        # RHEL/CentOS/Fedora
+        print_message "Using NodeSource repository for RHEL/CentOS." "正在使用 NodeSource 仓库 (RHEL/CentOS)。"
+        curl -fsSL https://rpm.nodesource.com/setup_$NODE_VERSION.x | sudo bash -
+        sudo yum install -y nodejs
+    elif [[ -f /etc/SuSE-release ]]; then
+        # OpenSUSE/SLES
+        print_message "Unsupported automatic Node.js installation for SUSE/OpenSUSE." "不支持 SUSE/OpenSUSE 的自动 Node.js 安装。"
+        return 1
+    elif [[ -f /etc/arch-release ]]; then
+        # Arch Linux
+        print_message "Installing Node.js via pacman for Arch Linux." "正在通过 pacman 安装 Node.js (Arch Linux)。"
+        sudo pacman -S --noconfirm nodejs npm
+    elif [[ -f /etc/alpine-release ]]; then
+        # Alpine Linux
+        print_message "Installing Node.js via apk for Alpine Linux." "正在通过 apk 安装 Node.js (Alpine Linux)。"
+        sudo apk add nodejs npm
+    else
+        print_message "Unsupported Linux distribution. Please install Node.js/npm manually." "不支持的 Linux 发行版。请手动安装 Node.js/npm。"
+        return 1
+    fi
+
+    if command -v node &>/dev/null && command -v npm &>/dev/null; then
+        print_message "Node.js and npm installation completed!" "Node.js 和 npm 安装完成!"
+        return 0
+    else
+        print_message "Node.js and npm installation failed! Please check system logs." "Node.js 和 npm 安装失败! 请检查系统日志。"
+        return 1
+    fi
+}
+
+# 安装 Supervisor
+install_supervisor() {
+    # 判断系统
+    if [[ -f /etc/debian_version ]]; then
+        PM=apt-get
+    elif [[ -f /etc/redhat-release ]]; then
+        PM=yum
+    elif [[ -f /etc/SuSE-release ]]; then
+        PM=zypper
+    elif [[ -f /etc/arch-release ]]; then
+        PM=pacman
+    elif [[ -f /etc/alpine-release ]]; then
+        PM=apk
+    else
+        print_message "Unsupported Linux distribution. Please install supervisor manually." "不支持的 Linux 发行版。请手动安装 supervisor。"
+        return 1
+    fi
+
+    if command -v supervisorctl >/dev/null; then
+        print_message "Supervisor installed!" "Supervisor 已安装!"
+    else
+        print_message "Supervisor not found, installing..." "未找到 Supervisor,正在安装..."
+        # 安装 Supervisor
+        case $PM in
+        apt-get)
+            sudo apt-get update
+            sudo apt-get install -y supervisor
+            ;;
+        yum)
+            sudo yum install -y epel-release
+            sudo yum install -y supervisor
+            ;;
+        zypper)
+            sudo zypper install -y supervisor
+            ;;
+        apk)
+            sudo apk add supervisor
+            ;;
+        pacman)
+            sudo pacman -S supervisor
+            ;;
+        esac
+
+        # 激活
+        case $PM in
+        yum)
+            sudo service supervisord start
+            sudo chkconfig supervisord on
+            ;;
+        *)
+            # 适用于大多数使用 systemd 的系统
+            sudo systemctl start supervisor.service
+            sudo systemctl enable supervisor.service
+            ;;
+        esac
+
+        if command -v supervisorctl >/dev/null; then
+            print_message "Supervisor installation completed!" "Supervisor 安装完成!"
+        else
+            print_message "Supervisor installation failed! Please check logs." "Supervisor 安装失败! 请检查日志。"
+            return 1
+        fi
+    fi
+    return 0
+}
+
+# ===============================================
+# 4. 配置和权限函数
+# ===============================================
+
 set_permissions() {
-  if [ ! -d "/home/www" ]; then
-    mkdir -p /home/www
-    chown www:www /home/www
-  fi
-  chown -R www:www ./
-  chmod -R 755 ./
-  chmod -R 777 storage/
+    print_message "Setting Laravel directory permissions (www)..." "正在设置 Laravel 目录权限 (www)..."
+    if [ ! -d "/home/www" ]; then
+        mkdir -p /home/www
+        chown www:www /home/www
+    fi
+    chmod -R 755 storage bootstrap/cache public/assets
+    chown -R www:www storage bootstrap/cache public/assets
 }
 
-# 设置定时任务
+# ===============================================
+# 5. 服务配置函数
+# ===============================================
+
 set_schedule() {
-  cmd="php $PWD/artisan schedule:run >> /dev/null 2>&1"
-  cronjob="* * * * * $cmd"
+    SCHEDULE_CMD="php $(pwd)/artisan schedule:run >> /dev/null 2>&1"
 
-  if (crontab -u www -l | grep -q -F "$cmd"); then
-    echo -e "\e[36m定时任务已存在,无需重复设置。\e[0m"
-  else
-    (
-      crontab -u www -l
-      echo "$cronjob"
-    ) | crontab -u www -
-    echo -e "\e[32m定时任务设置完成!\e[0m"
-  fi
+    # 尝试使用 www 用户的 crontab
+    if command -v crontab >/dev/null && id -u www &>/dev/null; then
+        USER_CRON="www"
+    else
+        # 如果 www 用户不存在,则使用当前用户
+        USER_CRON=$(whoami)
+    fi
+
+    if crontab -u $USER_CRON -l 2>/dev/null | grep -q "$SCHEDULE_CMD"; then
+        print_message "Schedule already exists in crontab for user $USER_CRON." "定时任务已存在于用户 $USER_CRON 的 crontab 中。"
+    else
+        # 添加定时任务
+        (crontab -u $USER_CRON -l 2>/dev/null; echo "* * * * * $SCHEDULE_CMD") | crontab -u $USER_CRON -
+        print_message "Schedule task added to crontab for user $USER_CRON." "定时任务已添加到用户 $USER_CRON 的 crontab。"
+    fi
 }
 
-# 设置Horizon
 set_horizon() {
-  if [ ! -f /etc/supervisor/conf.d/horizon.conf ]; then
-    cat <<EOF | sudo tee -a /etc/supervisor/conf.d/horizon.conf >/dev/null
+    # 创建 Horizon 配置文件 (用户使用 www 以匹配权限)
+    if [ ! -f /etc/supervisor/conf.d/horizon.conf ]; then
+        cat > /etc/supervisor/conf.d/horizon.conf <<EOF
 [program:horizon]
 process_name=%(program_name)s
-command=php $PWD/artisan horizon
+command=php $(pwd)/artisan horizon
 autostart=true
 autorestart=true
 user=www
 redirect_stderr=true
-stdout_logfile=$PWD/storage/logs/horizon.log
+stdout_logfile=$(pwd)/storage/logs/horizon.log
 stopwaitsecs=3600
 EOF
-    sudo supervisorctl reread
-    sudo supervisorctl update
-    sudo supervisorctl start horizon
-    echo -e "\e[32mHorizon configuration completed! | Horizon 配置完成!\e[0m"
-  else
-    echo -e "\e[36mHorizon already configured! | Horizon 已配置!\e[0m"
-  fi
+
+        # 重新加载 supervisor 配置
+        supervisorctl reread
+        supervisorctl update
+        supervisorctl start horizon
+
+        print_message "Horizon service configured and started." "Horizon 服务已配置并启动。"
+    else
+        # 优化:如果配置已存在,在更新后强制重启服务
+        print_message "Horizon supervisor configuration already exists, attempting to restart service." "Horizon supervisor 配置已存在,尝试重启服务。"
+        supervisorctl restart horizon
+    fi
 }
 
-# 更新旧的队列设置
+configure_reverb() {
+    # 尝试加载 .env 文件,如果存在
+    if [ -f ".env" ]; then
+        # 从 .env 文件中获取 APP_URL 的值
+        APP_URL=$(grep '^APP_URL=' .env | cut -d '=' -f2)
+    fi
+
+    # 检查 .env 文件中是否已存在 Reverb 配置
+    if ! grep -q "REVERB_APP_KEY" .env || [ -z "$(grep "REVERB_APP_KEY=" .env | cut -d '=' -f2)" ]; then
+        print_message "Adding Reverb configuration to .env file..." "正在向 .env 文件添加 Reverb 配置..."
+
+        REVERB_APP_KEY=$(tr -dc 'a-zA-Z0-9' </dev/urandom | head -c 32)
+        REVERB_APP_SECRET=$(tr -dc 'a-zA-Z0-9' </dev/urandom | head -c 32)
+        REVERB_APP_ID=$(tr -dc '0-9' </dev/urandom | head -c 6)
+        REVERB_SERVER_PATH=/broadcasting
+
+        # 替换或添加 Reverb 配置到 .env 文件
+        sed -i "/^BROADCAST_DRIVER=/d" .env
+        sed -i "/^REVERB_APP_KEY=/d" .env
+        sed -i "/^REVERB_APP_SECRET=/d" .env
+        sed -i "/^REVERB_APP_ID=/d" .env
+        sed -i "/^REVERB_SERVER_PATH=/d" .env
+        sed -i "/^VITE_REVERB_APP_KEY=/d" .env
+        sed -i "/^VITE_REVERB_HOST=/d" .env
+        sed -i "/^VITE_REVERB_PORT=/d" .env
+
+        echo "BROADCAST_DRIVER=reverb" >> .env
+        echo "REVERB_APP_KEY=$REVERB_APP_KEY" >> .env
+        echo "REVERB_APP_SECRET=$REVERB_APP_SECRET" >> .env
+        echo "REVERB_APP_ID=$REVERB_APP_ID" >> .env
+        echo "REVERB_SERVER_PATH=$REVERB_SERVER_PATH" >> .env
+        echo "VITE_REVERB_APP_KEY=\"\${REVERB_APP_KEY}\"" >> .env
+        echo "VITE_REVERB_HOST=\"\${REVERB_HOST}\"" >> .env
+        echo "VITE_REVERB_PORT=\"\${REVERB_PORT}\"" >> .env
+        print_message "Reverb configuration added to .env file." "Reverb 配置已添加到 .env 文件。"
+    else
+        print_message "Reverb configuration already exists in .env file." "Reverb 配置已存在于 .env 文件中。"
+    fi
+
+    # 创建 Reverb 服务的 supervisor 配置 (用户使用 www 以匹配权限)
+    if [ ! -f "/etc/supervisor/conf.d/reverb.conf" ]; then
+        cat > /etc/supervisor/conf.d/reverb.conf <<EOF
+[program:reverb]
+process_name=%(program_name)s
+command=php $(pwd)/artisan reverb:start
+autostart=true
+autorestart=true
+user=www
+redirect_stderr=true
+stdout_logfile=$(pwd)/storage/logs/reverb.log
+stopwaitsecs=3600
+EOF
+
+        # 重新加载 supervisor 配置
+        supervisorctl reread
+        supervisorctl update
+        supervisorctl start reverb
+
+        print_message "Reverb supervisor configuration created and started." "Reverb supervisor 配置已创建并启动。"
+    else
+        print_message "Reverb supervisor configuration already exists, attempting to restart service." "Reverb supervisor 配置已存在,尝试重启服务。"
+        supervisorctl restart reverb
+    fi
+}
+
+build_frontend_assets() {
+    # 检查 Node.js/npm 是否真的可用,以防安装失败
+    if ! command -v node &>/dev/null || ! command -v npm &>/dev/null; then
+        print_message "Cannot build frontend assets. Node.js/npm is not available." "无法构建前端资源。Node.js/npm 不可用。"
+        return
+    fi
+
+    print_message "Installing frontend dependencies..." "安装前端依赖..."
+    npm install
+
+    print_message "Building frontend assets..." "构建前端资源..."
+    npm run build
+}
+
+# ===============================================
+# 6. 清理函数
+# ===============================================
+
 update_old_queue() {
-  if crontab -l | grep -q "queue.sh"; then
-    crontab_content=$(crontab -l | grep -v "queue.sh")
-    echo "$crontab_content" | crontab -
-    echo -e "\e[32mOld queue.sh cron job removed! | 旧的 queue.sh 定时任务已移除!\e[0m"
-  fi
+    # 检查旧的队列设置
+    if crontab -l 2>/dev/null | grep -q "artisan queue:work"; then
+        print_message "Removing old queue worker from crontab..." "正在从 crontab 中移除旧的队列工作者..."
+        crontab -l | grep -v "artisan queue:work" | crontab -
+    fi
+
+    # 检查旧的队列设置
+    if crontab -l 2>/dev/null | grep -q "queue.sh"; then
+        print_message "Removing old queue.sh cron job..." "正在移除旧的 queue.sh 定时任务..."
+        crontab -l | grep -v "queue.sh" | crontab -
+    fi
 
-  set_horizon
+    set_horizon
 }
+
+clean_files() {
+    print_message "Cleaning up unnecessary files..." "正在清理不必要的文件..."
+
+    rm -f .gitignore .styleci.yml
+
+    rm -rf .htaccess 404.html index.html
+
+    if [ -f .user.ini ]; then
+        # 尝试移除文件锁
+        if command -v chattr >/dev/null; then
+            chattr -i .user.ini
+        fi
+        rm -f .user.ini
+        print_message "Cleaned up unnecessary files" "不必要的文件已清理。"
+    fi
+}

+ 42 - 2
update.sh

@@ -2,6 +2,9 @@
 # 设置工作目录为脚本所在的目录
 cd "$(dirname "$0")" || exit 1
 
+# 临时下载最新的 lib.sh 到当前目录(覆盖旧版本)
+curl -o scripts/lib.sh https://raw.githubusercontent.com/ProxyPanel/ProxyPanel/master/scripts/lib.sh
+
 # 引入依赖脚本
 source scripts/lib.sh
 
@@ -19,7 +22,7 @@ php artisan migrate --force
 
 # 如果是演示环境,询问是否重置数据库
 if [[ $(grep -E '^APP_ENV=demo' .env) ]]; then
-    read -p "Reset demo database? [y/N] " -n 1 -r
+    read -p "Reset demo database? [Y/N] " -n 1 -r
     echo
     if [[ $REPLY =~ ^[Yy]$ ]]; then
         print_message "Resetting demo database..." "重置演示数据库..."
@@ -31,6 +34,18 @@ fi
 print_message "Optimizing application cache..." "优化应用缓存..."
 php artisan optimize
 
+# ===============================================
+# 系统依赖安装与检查
+# ===============================================
+
+# 确保 Supervisor 存在,供 Horizon/Reverb 使用
+print_message "Checking and installing Supervisor..." "检查并安装 Supervisor..."
+install_supervisor
+
+# 确保 Node.js/npm 存在,供前端构建使用
+print_message "Checking and installing Node.js/npm..." "检查并安装 Node.js/npm..."
+install_nodejs_npm
+
 # 检查Composer
 print_message "Checking Composer..." "检查Composer..."
 check_composer
@@ -39,14 +54,39 @@ check_composer
 print_message "Updating packages via Composer..." "通过Composer更新程序包..."
 composer update --no-interaction --no-dev --optimize-autoloader
 
+# ===============================================
+# 服务配置和资源构建
+# ===============================================
+
 # 设置权限
 set_permissions
 
 # 更新旧的队列设置
 update_old_queue
 
+print_message "Updating .env configuration..." "更新 .env 配置..."
+if [[ -f ".env" ]]; then
+    # 将 FORCE_HTTPS 替换为 SESSION_SECURE_COOKIE
+    if grep -q "^FORCE_HTTPS=" .env; then
+        sed -i 's/^FORCE_HTTPS=/SESSION_SECURE_COOKIE=/' .env
+        print_message "Updated FORCE_HTTPS to SESSION_SECURE_COOKIE in .env" ".env 中的 FORCE_HTTPS 已更新为 SESSION_SECURE_COOKIE"
+    else
+        print_message "No FORCE_HTTPS found in .env" ".env 中未找到 FORCE_HTTPS"
+    fi
+else
+    print_message ".env file not found" "未找到 .env 文件"
+fi
+
+# 配置Reverb WebSocket服务
+print_message "Configuring Reverb WebSocket service..." "配置Reverb WebSocket服务..."
+configure_reverb
+
+# 构建前端资源
+print_message "Building frontend assets..." "构建前端资源..."
+build_frontend_assets
+
 # 检查最新的IP数据库文件
 print_message "Updating IP database files..." "更新本地IP数据库文件..."
-(cd scripts/ && bash download_dbs.sh)
+cd scripts/ && bash download_dbs.sh
 
 print_message "Panel update completed successfully!" "面板更新完成!"

部分文件因为文件数量过多而无法显示