فهرست منبع

Standardize Broadcasting & Broadcast-Based Existing User Checks in VNet

BrettonYe 1 هفته پیش
والد
کامیت
d1141ac14d

+ 2 - 2
app/Console/Commands/VNetReload.php

@@ -2,7 +2,7 @@
 
 namespace App\Console\Commands;
 
-use App\Jobs\VNet\reloadNode;
+use App\Jobs\VNet\ReloadNode;
 use App\Models\Node;
 use Illuminate\Console\Command;
 use Log;
@@ -19,7 +19,7 @@ class VNetReload extends Command
 
         $nodes = Node::whereStatus(1)->whereType(4)->get();
         if ($nodes->isNotEmpty()) {
-            reloadNode::dispatchSync($nodes);
+            ReloadNode::dispatchSync($nodes);
         }
 
         $jobTime = round(microtime(true) - $startTime, 4);

+ 31 - 0
app/Events/UserVNetTasks.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 UserVNetTasks implements ShouldBroadcastNow
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    public function __construct(
+        public string $type,
+        public array $data = [],
+        public ?int $userId = null
+    ) {
+    }
+
+    public function broadcastOn(): Channel
+    {
+        return new Channel('user.'.$this->type.'.'.($this->userId ?? 'all'));
+    }
+
+    public function broadcastAs(): string
+    {
+        return 'user.vnet.tasks';
+    }
+}

+ 2 - 2
app/Http/Controllers/Admin/NodeController.php

@@ -8,7 +8,7 @@ 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\Jobs\VNet\ReloadNode;
 use App\Models\Country;
 use App\Models\Label;
 use App\Models\Level;
@@ -315,7 +315,7 @@ class NodeController extends Controller
             dispatch(static function () use ($n, $node) {
                 $ret = ['nodeId' => $n->id];
                 try {
-                    $ret += (new reloadNode($n))->handle();
+                    $ret += (new ReloadNode($n))->handle();
                 } catch (Exception $e) {
                     Log::error("节点 [{$n->id}] 重载失败: ".$e->getMessage());
                     $ret += ['error' => $e->getMessage()];

+ 29 - 5
app/Http/Controllers/Admin/UserController.php

@@ -2,11 +2,12 @@
 
 namespace App\Http\Controllers\Admin;
 
+use App\Events\UserVNetTasks;
 use App\Helpers\ProxyConfig;
 use App\Http\Controllers\Controller;
 use App\Http\Requests\Admin\UserStoreRequest;
 use App\Http\Requests\Admin\UserUpdateRequest;
-use App\Jobs\VNet\getUser;
+use App\Jobs\VNet\GetUser;
 use App\Models\Level;
 use App\Models\Node;
 use App\Models\Order;
@@ -325,13 +326,36 @@ class UserController extends Controller
 
     public function VNetInfo(User $user): JsonResponse
     {
-        $nodes = $user->nodes()->whereType(4)->get(['node.id', 'node.name']);
-        $nodeList = (new getUser)->existsinVNet($user);
+        // 获取用户关联的 VNet 节点
+        $nodes = $user->nodes()->whereType(4)->get();
 
+        // 立即发送节点列表信息给前端
+        broadcast(new UserVNetTasks('check', ['nodeList' => $nodes->pluck('name', 'id')->toArray()], $user->id));
+
+        // 创建 GetUser 实例
+        $getUser = new GetUser;
+
+        // 异步检查用户在各节点的可用性
         foreach ($nodes as $node) {
-            $node->avaliable = in_array($node->id, $nodeList, true) ? '✔️' : '❌';
+            dispatch(static function () use ($user, $node, $getUser) {
+                $ret = ['nodeId' => $node->id, 'available' => false];
+                try {
+                    // 发送请求检查用户是否在该节点上
+                    $userList = $getUser->list($node);
+
+                    if ($userList && is_array($userList)) {
+                        // 检查用户是否在返回的列表中
+                        $ret['available'] = in_array($user->id, $userList, true);
+                    }
+                } catch (Exception $e) {
+                    Log::warning('【用户列表】获取失败(推送地址:'.($node->server ?: $node->ips()[0]).':'.$node->push_port.'):'.$e->getMessage());
+                }
+
+                // 广播检查结果
+                broadcast(new UserVNetTasks('check', $ret, $user->id));
+            });
         }
 
-        return response()->json(['status' => 'success', 'data' => $nodes]);
+        return response()->json(['status' => 'success', 'message' => trans('common.success_item', ['attribute' => trans('admin.user.connection_test')])]);
     }
 }

+ 1 - 1
app/Jobs/VNet/addUser.php

@@ -16,7 +16,7 @@ use Illuminate\Queue\SerializesModels;
 use Log;
 use Throwable;
 
-class addUser implements ShouldQueue
+class AddUser implements ShouldQueue
 {
     use Dispatchable;
     use InteractsWithQueue;

+ 1 - 1
app/Jobs/VNet/delUser.php

@@ -15,7 +15,7 @@ use Illuminate\Queue\SerializesModels;
 use Log;
 use Throwable;
 
-class delUser implements ShouldQueue
+class DelUser implements ShouldQueue
 {
     use Dispatchable;
     use InteractsWithQueue;

+ 3 - 3
app/Jobs/VNet/editUser.php

@@ -16,7 +16,7 @@ use Illuminate\Queue\SerializesModels;
 use Log;
 use Throwable;
 
-class editUser implements ShouldQueue
+class EditUser implements ShouldQueue
 {
     use Dispatchable;
     use InteractsWithQueue;
@@ -47,7 +47,7 @@ class editUser implements ShouldQueue
     public function handle(): void
     {
         foreach ($this->nodes as $node) {
-            $list = (new getUser)->list($node);
+            $list = (new GetUser)->list($node);
             if ($list && in_array($this->data['uid'], $list, true)) { // 如果用户已存在节点内,则执行修改;否则为添加
                 if ($node->is_ddns) {
                     $this->send($node->server.':'.$node->push_port, $node->auth->secret);
@@ -57,7 +57,7 @@ class editUser implements ShouldQueue
                     }
                 }
             } else {
-                addUser::dispatch($this->data['uid'], $node);
+                AddUser::dispatch($this->data['uid'], $node);
             }
         }
     }

+ 1 - 15
app/Jobs/VNet/getUser.php

@@ -3,27 +3,13 @@
 namespace App\Jobs\VNet;
 
 use App\Models\Node;
-use App\Models\User;
 use Arr;
 use Exception;
 use Http;
 use Log;
 
-class getUser
+class GetUser
 {
-    public function existsinVNet(User $user): array
-    {
-        $nodeList = [];
-        foreach ($user->nodes()->whereType(4)->get() as $node) {
-            $list = $this->list($node);
-            if ($list && in_array($user->id, $list, true)) {
-                $nodeList[] = $node->id;
-            }
-        }
-
-        return $nodeList;
-    }
-
     public function list(Node $node, string $mode = 'uid'): false|array
     {
         $list = $this->send(($node->server ?: $node->ips()[0]).':'.$node->push_port, $node->auth->secret);

+ 1 - 1
app/Jobs/VNet/reloadNode.php

@@ -15,7 +15,7 @@ use Illuminate\Queue\SerializesModels;
 use Log;
 use Throwable;
 
-class reloadNode implements ShouldQueue
+class ReloadNode implements ShouldQueue
 {
     use Dispatchable;
     use InteractsWithQueue;

+ 2 - 2
app/Observers/NodeObserver.php

@@ -2,7 +2,7 @@
 
 namespace App\Observers;
 
-use App\Jobs\VNet\reloadNode;
+use App\Jobs\VNet\ReloadNode;
 use App\Models\Node;
 use App\Utils\DDNS;
 use Arr;
@@ -79,7 +79,7 @@ class NodeObserver
         }
 
         if ((int) $node->type === 4) {
-            reloadNode::dispatch($node);
+            ReloadNode::dispatch($node);
         }
     }
 

+ 3 - 3
app/Observers/UserGroupObserver.php

@@ -2,7 +2,7 @@
 
 namespace App\Observers;
 
-use App\Jobs\VNet\reloadNode;
+use App\Jobs\VNet\ReloadNode;
 use App\Models\Node;
 use App\Models\UserGroup;
 use Arr;
@@ -13,7 +13,7 @@ class UserGroupObserver
     {
         $nodes = Node::whereType(4)->whereIn('id', $userGroup->nodes)->get();
         if ($nodes->isNotEmpty()) {
-            reloadNode::dispatch($nodes);
+            ReloadNode::dispatch($nodes);
         }
     }
 
@@ -25,7 +25,7 @@ class UserGroupObserver
                 ->whereIn('id', array_diff($userGroup->nodes ?? [], $userGroup->getOriginal('nodes') ?? []))
                 ->get();
             if ($nodes->isNotEmpty()) {
-                reloadNode::dispatch($nodes);
+                ReloadNode::dispatch($nodes);
             }
         }
     }

+ 13 - 13
app/Observers/UserObserver.php

@@ -2,9 +2,9 @@
 
 namespace App\Observers;
 
-use App\Jobs\VNet\addUser;
-use App\Jobs\VNet\delUser;
-use App\Jobs\VNet\editUser;
+use App\Jobs\VNet\AddUser;
+use App\Jobs\VNet\DelUser;
+use App\Jobs\VNet\EditUser;
 use App\Models\User;
 use App\Utils\Helpers;
 use Arr;
@@ -17,7 +17,7 @@ class UserObserver
 
         $allowNodes = $user->nodes()->whereType(4)->get();
         if ($allowNodes->isNotEmpty()) {
-            addUser::dispatch($user->id, $allowNodes);
+            AddUser::dispatch($user->id, $allowNodes);
         }
     }
 
@@ -31,35 +31,35 @@ class UserObserver
                 $oldAllowNodes = $user->nodes($user->getOriginal('level'), $user->getOriginal('user_group_id'))->whereType(4)->get();
                 if ($enableChange) {
                     if ($user->enable === 0 && $oldAllowNodes->isNotEmpty()) {
-                        delUser::dispatch($user->id, $oldAllowNodes);
+                        DelUser::dispatch($user->id, $oldAllowNodes);
                     } elseif ($user->enable === 1 && $allowNodes->isNotEmpty()) {
-                        addUser::dispatch($user->id, $allowNodes);
+                        AddUser::dispatch($user->id, $allowNodes);
                     }
                 } else {
                     $old = $oldAllowNodes->diff($allowNodes); // old 有 allow 没有
                     $new = $allowNodes->diff($oldAllowNodes); // allow 有 old 没有
                     if ($old->isNotEmpty()) {
-                        delUser::dispatch($user->id, $old);
+                        DelUser::dispatch($user->id, $old);
                     }
                     if ($new->isNotEmpty()) {
-                        addUser::dispatch($user->id, $new);
+                        AddUser::dispatch($user->id, $new);
                     }
                     if (Arr::hasAny($changes, ['port', 'passwd', 'speed_limit'])) {
                         $same = $allowNodes->intersect($oldAllowNodes); // 共有部分
                         if ($same->isNotEmpty()) {
-                            editUser::dispatch($user, $same);
+                            EditUser::dispatch($user, $same);
                         }
                     }
                 }
             } elseif ($allowNodes->isNotEmpty()) {
                 if ($enableChange) {
                     if ($user->enable === 1) { // TODO: 由于vnet未正确使用enable字段,临时解决方案
-                        addUser::dispatch($user->id, $allowNodes);
+                        AddUser::dispatch($user->id, $allowNodes);
                     } else {
-                        delUser::dispatch($user->id, $allowNodes);
+                        DelUser::dispatch($user->id, $allowNodes);
                     }
                 } elseif (Arr::hasAny($changes, ['port', 'passwd', 'speed_limit'])) {
-                    editUser::dispatch($user, $allowNodes);
+                    EditUser::dispatch($user, $allowNodes);
                 }
             }
         }
@@ -73,7 +73,7 @@ class UserObserver
     {
         $allowNodes = $user->nodes()->whereType(4)->get();
         if ($allowNodes->isNotEmpty()) {
-            delUser::dispatch($user->id, $allowNodes);
+            DelUser::dispatch($user->id, $allowNodes);
         }
     }
 }

+ 15 - 17
public/assets/js/config/common.js

@@ -115,15 +115,15 @@ function showConfirm(options) {
         icon: "question",
         allowEnterKey: false,
         showCancelButton: true,
-        cancelButtonText: typeof TRANS !== "undefined" ? TRANS.btn.close : "Cancel",
-        confirmButtonText: typeof TRANS !== "undefined" ? TRANS.btn.confirm : "Confirm",
+        cancelButtonText: i18n('btn.close'),
+        confirmButtonText: i18n('btn.confirm'),
         ...options
     };
 
-    alertOptions.title = alertOptions.title || (typeof TRANS !== "undefined" ? TRANS.confirm_title : "Confirm");
+    alertOptions.title = alertOptions.title || i18n('confirm_title');
 
     if (!alertOptions.html && !alertOptions.text) {
-        alertOptions.text = typeof TRANS !== "undefined" ? TRANS.confirm_action : "Are you sure you want to perform this action?";
+        alertOptions.text = i18n('confirm_action');
     }
 
     showAlert(alertOptions).then((result) => {
@@ -227,7 +227,7 @@ function handleErrors(xhr, options = {}) {
                     }
                 } else {
                     // 如果没有提供 form,回退到 swal 显示
-                    showMessage({title: xhr.responseJSON.message || (typeof TRANS !== "undefined" ? TRANS.operation_failed : "Operation failed"), html: buildErrorHtml(errors), icon: "error"});
+                    showMessage({title: xhr.responseJSON.message || i18n('operation_failed'), html: buildErrorHtml(errors), icon: "error"});
                 }
                 break;
 
@@ -235,14 +235,14 @@ function handleErrors(xhr, options = {}) {
                 if (settings.element) {
                     $(settings.element).html(buildErrorHtml(errors)).show();
                 } else {
-                    showMessage({title: xhr.responseJSON.message || (typeof TRANS !== "undefined" ? TRANS.operation_failed : "Operation failed"), html: buildErrorHtml(errors), icon: "error"});
+                    showMessage({title: xhr.responseJSON.message || i18n('operation_failed'), html: buildErrorHtml(errors), icon: "error"});
                 }
                 break;
 
             case 'swal':
             default:
                 showMessage({
-                    title: xhr.responseJSON.message || (typeof TRANS !== "undefined" ? TRANS.operation_failed : "Operation failed"),
+                    title: xhr.responseJSON.message || i18n('operation_failed'),
                     html: buildErrorHtml(errors),
                     icon: "error"
                 });
@@ -252,7 +252,7 @@ function handleErrors(xhr, options = {}) {
     }
 
     // 其它错误
-    const errorMessage = xhr.responseJSON?.message || xhr.statusText || (typeof TRANS !== "undefined" ? TRANS.request_failed : "Request failed");
+    const errorMessage = xhr.responseJSON?.message || xhr.statusText || i18n('request_failed');
     
     // 提取公共的 showMessage 调用
     const showMessageOptions = {title: errorMessage, icon: "error"};
@@ -314,7 +314,7 @@ function handleResponse(response, options = {}) {
 
         if (settings.showMessage) {
             showMessage({
-                title: response.message || (typeof TRANS !== "undefined" ? TRANS.operation_success : "Operation successful"),
+                title: response.message || i18n('operation_success'),
                 icon: "success",
                 showConfirmButton: false,
                 callback: successCallback
@@ -329,7 +329,7 @@ function handleResponse(response, options = {}) {
 
         if (settings.showMessage) {
             showMessage({
-                title: response.message || (typeof TRANS !== "undefined" ? TRANS.operation_failed : "Operation failed"),
+                title: response.message || i18n('operation_failed'),
                 icon: "error",
                 showConfirmButton: true,
                 callback: errorCallback
@@ -389,8 +389,8 @@ function initAutoSubmitSelects(formSelector = "form:not(.modal-body form)", excl
 function copyToClipboard(text, options = {}) {
     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"
+        successMessage: i18n('copy.success'),
+        errorMessage: i18n('copy.failed')
     }, options);
 
     if (navigator.clipboard && window.isSecureContext) {
@@ -449,14 +449,12 @@ function copyToClipboard(text, options = {}) {
  */
 function confirmDelete(url, name, attribute, options = {}) {
     const defaults = {
-        titleMessage: typeof TRANS !== "undefined" ? TRANS.warning : "Warning",
+        titleMessage: i18n('warning'),
     };
 
     let text = options.text;
-    if (!text && typeof TRANS !== "undefined" && TRANS.confirm?.delete) {
-        text = TRANS.confirm.delete.replace("{attribute}", attribute || "").replace("{name}", name || "");
-    } else if (!text) {
-        text = typeof TRANS !== "undefined" ? (TRANS.confirm_delete || "Are you sure you want to delete {attribute} [{name}]?").replace("{attribute}", attribute || "").replace("{name}", name || "") : `Are you sure you want to delete ${attribute || ""} [${name || ""}]?`;
+    if (!text && !options.html) {
+        text = i18n('confirm.delete').replace("{attribute}", attribute || "").replace("{name}", name || "");
     }
 
     showConfirm({

+ 3 - 0
resources/js/bootstrap.js

@@ -8,6 +8,7 @@ import axios from "axios";
 import Echo from "laravel-echo";
 
 import Pusher from "pusher-js";
+import broadcastingManager from "./broadcastingManager";
 
 window.axios = axios;
 
@@ -25,3 +26,5 @@ window.Echo = new Echo({
     forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? "https") === "https",
     enabledTransports: ["ws", "wss"],
 });
+
+window.broadcastingManager = broadcastingManager;

+ 219 - 0
resources/js/broadcastingManager.js

@@ -0,0 +1,219 @@
+/**
+ * Laravel Reverb 广播统一管理模块
+ * 提供通用的 WebSocket 连接管理、频道订阅和事件处理功能
+ */
+
+class BroadcastingManager {
+    constructor() {
+        this.channels = new Map();
+        this.pollingIntervals = new Map();
+        this.errorDisplayed = false;
+        this.connectionState = 'unknown';
+    }
+
+    /**
+     * 检查 Echo 是否可用
+     * @returns {boolean}
+     */
+    isEchoAvailable() {
+        return typeof Echo !== 'undefined' && Echo !== null;
+    }
+
+    /**
+     * 检查连接是否正常
+     * @returns {boolean}
+     */
+    isConnected() {
+        if (!this.isEchoAvailable()) return false;
+        
+        const conn = this.getConnection();
+        if (!conn) return false;
+        
+        const state = conn.state?.current ?? conn.readyState;
+        return state === 'connected' || state === 'open' || state === 1;
+    }
+
+    /**
+     * 获取连接对象
+     * @returns {Object|null}
+     */
+    getConnection() {
+        if (!this.isEchoAvailable()) return null;
+        return Echo.connector?.pusher?.connection || Echo.connector?.socket || null;
+    }
+
+    /**
+     * 显示错误信息
+     * @param {string} message 
+     */
+    handleError(message) {
+        if (!this.errorDisplayed && !this.isConnected()) {
+            if (typeof showMessage !== 'undefined') {
+                showMessage({
+                    title: i18n('broadcast.error'),
+                    message: message,
+                    icon: 'error',
+                    showConfirmButton: true
+                });
+            } else {
+                console.error(message);
+            }
+            this.errorDisplayed = true;
+        }
+    }
+
+    /**
+     * 清除错误状态
+     */
+    clearError() {
+        this.errorDisplayed = false;
+    }
+
+    /**
+     * 订阅频道并监听事件
+     * @param {string} channelName - 频道名称
+     * @param {string} event - 事件名称
+     * @param {Function} handler - 事件处理函数
+     * @returns {boolean} 是否订阅成功
+     */
+    subscribe(channelName, event, handler) {
+        // 清理同名频道(如果存在)
+        this.unsubscribe(channelName);
+        
+        if (!this.isEchoAvailable()) {
+            this.handleError(i18n('broadcast.websocket_unavailable'));
+            return false;
+        }
+
+        try {
+            const channel = Echo.channel(channelName);
+            channel.listen(event, handler);
+            this.channels.set(channelName, channel);
+            
+            // 绑定连接状态事件
+            const conn = this.getConnection();
+            if (conn?.bind) {
+                conn.bind('connected', () => {
+                    this.connectionState = 'connected';
+                    this.clearError();
+                });
+                conn.bind('disconnected', () => {
+                    this.connectionState = 'disconnected';
+                    this.handleError(i18n('broadcast.websocket_disconnected'));
+                });
+            }
+            
+            return true;
+        } catch (e) {
+            if (!this.isConnected()) {
+                this.handleError(`${i18n('broadcast.setup_failed')}: ${e?.message || e}`);
+            }
+            return false;
+        }
+    }
+
+    /**
+     * 取消订阅频道
+     * @param {string} channelName 
+     */
+    unsubscribe(channelName) {
+        if (this.channels.has(channelName)) {
+            try {
+                const channel = this.channels.get(channelName);
+                channel.stopListening();
+                Echo.leave(channelName);
+            } catch (e) {
+                // 忽略错误
+            }
+            this.channels.delete(channelName);
+        }
+    }
+
+    /**
+     * 清理所有频道
+     */
+    cleanup() {
+        for (const channelName of this.channels.keys()) {
+            this.unsubscribe(channelName);
+        }
+        this.channels.clear();
+    }
+
+    /**
+     * 启动轮询降级机制
+     * @param {string} intervalId - 轮询ID
+     * @param {Function} pollFunction - 轮询函数
+     * @param {number} interval - 轮询间隔(毫秒)
+     */
+    startPolling(intervalId, pollFunction, interval = 3000) {
+        this.stopPolling(intervalId);
+        const pollInterval = setInterval(pollFunction, interval);
+        this.pollingIntervals.set(intervalId, pollInterval);
+        return pollInterval;
+    }
+
+    /**
+     * 停止轮询
+     * @param {string} intervalId 
+     */
+    stopPolling(intervalId) {
+        if (this.pollingIntervals.has(intervalId)) {
+            clearInterval(this.pollingIntervals.get(intervalId));
+            this.pollingIntervals.delete(intervalId);
+        }
+    }
+
+    /**
+     * 停止所有轮询
+     */
+    stopAllPolling() {
+        for (const intervalId of this.pollingIntervals.keys()) {
+            this.stopPolling(intervalId);
+        }
+        this.pollingIntervals.clear();
+    }
+
+    /**
+     * 断开 Echo 连接
+     */
+    disconnect() {
+        try {
+            if (this.isEchoAvailable()) {
+                Echo.connector?.disconnect?.();
+            }
+        } catch (e) {
+            console.error(i18n('broadcast.disconnect_failed'), e);
+        }
+    }
+    
+    /**
+     * 等待连接建立
+     * @param {number} timeout - 超时时间(毫秒)
+     * @returns {Promise<boolean>}
+     */
+    waitForConnection(timeout = 5000) {
+        return new Promise((resolve) => {
+            if (this.isConnected()) {
+                resolve(true);
+                return;
+            }
+            
+            const startTime = Date.now();
+            const checkConnection = () => {
+                if (this.isConnected()) {
+                    resolve(true);
+                } else if (Date.now() - startTime > timeout) {
+                    resolve(false);
+                } else {
+                    setTimeout(checkConnection, 100);
+                }
+            };
+            
+            checkConnection();
+        });
+    }
+}
+
+// 导出单例
+const broadcastingManager = new BroadcastingManager();
+export default broadcastingManager;

+ 6 - 0
resources/lang/de/common.php

@@ -19,6 +19,12 @@ return [
         'custom' => 'Anpassen',
         'node_status' => 'Knoten-Status',
     ],
+    'broadcast' => [
+        'websocket_unavailable' => 'WebSocket ist nicht verfügbar. Bitte stellen Sie sicher, dass der Reverb-Server läuft und korrekt konfiguriert ist.',
+        'websocket_disconnected' => 'WebSocket-Verbindung wurde getrennt.',
+        'setup_failed' => 'Broadcast-Einrichtung fehlgeschlagen oder Verbindungsfehler',
+        'disconnect_failed' => 'Echo-Verbindung konnte nicht geschlossen werden',
+    ],
     'cancel' => 'Abbrechen',
     'change' => 'Ändern',
     'close' => 'Schließen',

+ 6 - 0
resources/lang/en/common.php

@@ -19,6 +19,12 @@ return [
         'custom' => 'Customize',
         'node_status' => 'Node Status',
     ],
+    'broadcast' => [
+        'websocket_unavailable' => 'WebSocket is unavailable. Please ensure the Reverb server is running and configured correctly.',
+        'websocket_disconnected' => 'WebSocket connection has been disconnected.',
+        'setup_failed' => 'Broadcast setup failed or connection error',
+        'disconnect_failed' => 'Failed to disconnect Echo connection',
+    ],
     'cancel' => 'Cancel',
     'change' => 'Change',
     'close' => 'Close',

+ 6 - 0
resources/lang/fa/common.php

@@ -19,6 +19,12 @@ return [
         'custom' => 'سفارشی',
         'node_status' => 'وضعیت نود',
     ],
+    'broadcast' => [
+        'websocket_unavailable' => 'WebSocket در دسترس نیست. لطفاً اطمینان حاصل کنید که سرور Reverb در حال اجرا بوده و به درستی پیکربندی شده است.',
+        'websocket_disconnected' => 'اتصال WebSocket قطع شده است.',
+        'setup_failed' => 'راه‌اندازی پخش زنده ناموفق بود یا خطای اتصال رخ داده است',
+        'disconnect_failed' => 'قطع اتصال Echo ناموفق بود',
+    ],
     'cancel' => 'لغو',
     'change' => 'تغییر',
     'close' => 'بستن',

+ 6 - 0
resources/lang/ja/common.php

@@ -19,6 +19,12 @@ return [
         'custom' => 'カスタム',
         'node_status' => 'ノード状態',
     ],
+    'broadcast' => [
+        'websocket_unavailable' => 'WebSocketが利用できません。Reverbサーバーが実行中で、正しく設定されていることを確認してください。',
+        'websocket_disconnected' => 'WebSocket接続が切断されました。',
+        'setup_failed' => 'ブロードキャストの設定に失敗したか、接続エラーが発生しました',
+        'disconnect_failed' => 'Echo接続の切断に失敗しました',
+    ],
     'cancel' => 'キャンセル',
     'change' => '変更',
     'close' => '閉じる',

+ 6 - 0
resources/lang/ko/common.php

@@ -19,6 +19,12 @@ return [
         'custom' => '사용자 정의',
         'node_status' => '노드 상태',
     ],
+    'broadcast' => [
+        'websocket_unavailable' => 'WebSocket을 사용할 수 없습니다. Reverb 서버가 실행 중이고 올바르게 구성되었는지 확인하세요.',
+        'websocket_disconnected' => 'WebSocket 연결이 끊어졌습니다.',
+        'setup_failed' => '방송 설정 실패 또는 연결 오류',
+        'disconnect_failed' => 'Echo 연결 종료 실패',
+    ],
     'cancel' => '취소',
     'change' => '변경',
     'close' => '닫기',

+ 6 - 0
resources/lang/ru/common.php

@@ -19,6 +19,12 @@ return [
         'custom' => 'Настроить',
         'node_status' => 'Статус узла',
     ],
+    'broadcast' => [
+        'websocket_unavailable' => 'WebSocket недоступен. Пожалуйста, убедитесь, что сервер Reverb запущен и правильно настроен.',
+        'websocket_disconnected' => 'Соединение WebSocket было разорвано.',
+        'setup_failed' => 'Настройка вещания не удалась или ошибка подключения',
+        'disconnect_failed' => 'Не удалось отключить соединение Echo',
+    ],
     'cancel' => 'Отмена',
     'change' => 'Изменить',
     'close' => 'Закрыть',

+ 6 - 0
resources/lang/vi/common.php

@@ -19,6 +19,12 @@ return [
         'custom' => 'Tùy chỉnh',
         'node_status' => 'Trạng thái nút',
     ],
+    'broadcast' => [
+        'websocket_unavailable' => 'WebSocket không khả dụng. Vui lòng đảm bảo máy chủ Reverb đang chạy và được cấu hình đúng.',
+        'websocket_disconnected' => 'Kết nối WebSocket đã bị ngắt.',
+        'setup_failed' => 'Thiết lập phát sóng thất bại hoặc lỗi kết nối',
+        'disconnect_failed' => 'Không thể ngắt kết nối Echo',
+    ],
     'cancel' => 'Hủy',
     'change' => 'Thay đổi',
     'close' => 'Đóng',

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

@@ -19,6 +19,12 @@ return [
         'custom' => '自定义',
         'node_status' => '节点状态',
     ],
+    'broadcast' => [
+        'websocket_unavailable' => 'WebSocket不可用。请确保Reverb服务器正在运行且配置正确。',
+        'websocket_disconnected' => 'WebSocket连接已断开。',
+        'setup_failed' => '广播设置失败或连接错误',
+        'disconnect_failed' => '关闭 Echo 连接失败',
+    ],
     'cancel' => '取消',
     'change' => '更换',
     'close' => '关闭',

+ 27 - 2
resources/views/_layout.blade.php

@@ -14,12 +14,13 @@
     <link href="{{ asset('favicon.ico') }}" rel="shortcut icon apple-touch-icon">
     <!-- 样式表/Stylesheets -->
     <link href="/assets/bundle/app.min.css" rel="stylesheet">
+    <link href="https://fonts.loli.net" rel="preconnect" crossorigin>
+    <link href="https://gstatic.loli.net" rel="preconnect" crossorigin>
+    <link href="https://cdn.jsdelivr.net" rel="preconnect" crossorigin>
     <link href="https://cdn.jsdelivr.net/npm/flag-icons@7/css/flag-icons.min.css" rel="stylesheet">
     @yield('layout_css')
     <!-- 字体/Fonts -->
     <link href="/assets/global/fonts/web-icons/web-icons.min.css" rel="stylesheet">
-    <link href="https://fonts.loli.net" rel="preconnect">
-    <link href="https://gstatic.loli.net" rel="preconnect" crossorigin>
     <link href="https://fonts.loli.net/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
           rel="stylesheet">
     <!-- Scripts -->
@@ -66,6 +67,30 @@
     <script src="/assets/global/js/Plugin/asscrollable.js"></script>
     <script src="/assets/global/js/Plugin/slidepanel.js"></script>
     <script>
+        // 初始化国际化管理器
+        window.i18n = function(key, fallback) {
+            const keys = key.split('.');
+            let value = window.i18n.translations || {};
+
+            for (let i = 0; i < keys.length; i++) {
+                if (value && typeof value === 'object' && value.hasOwnProperty(keys[i])) {
+                    value = value[keys[i]];
+                } else {
+                    return fallback || key;
+                }
+            }
+
+            return value || fallback || key;
+        };
+
+        // 初始化空的翻译对象
+        window.i18n.translations = {};
+
+        // 扩展翻译文本的方法
+        window.i18n.extend = function(additionalTranslations) {
+            window.i18n.translations = Object.assign({}, window.i18n.translations, additionalTranslations);
+        };
+
         // Create and append link element to load the font CSS asynchronously
         const link = document.createElement("link");
         link.rel = 'stylesheet';

+ 0 - 3
resources/views/admin/config/system.blade.php

@@ -3,7 +3,6 @@
     <link href="/assets/global/vendor/bootstrap-select/bootstrap-select.min.css" rel="stylesheet">
     <link href="/assets/global/vendor/switchery/switchery.min.css" rel="stylesheet">
     <link href="/assets/global/vendor/dropify/dropify.min.css" rel="stylesheet">
-    <link href="/assets/global/vendor/toastr/toastr.min.css" rel="stylesheet">
     <style>
         .hr-text::after {
             content: attr(data-content);
@@ -415,12 +414,10 @@
     <script src="/assets/global/vendor/bootstrap-select/bootstrap-select.min.js"></script>
     <script src="/assets/global/vendor/switchery/switchery.min.js"></script>
     <script src="/assets/global/vendor/dropify/dropify.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/switchery.js"></script>
     <script src="/assets/global/js/Plugin/responsive-tabs.js"></script>
     <script src="/assets/global/js/Plugin/tabs.js"></script>
-    <script src="/assets/global/js/Plugin/toastr.js"></script>
     <script src="/assets/custom/jump-tab.js"></script>
     <script src="/assets/global/js/Plugin/dropify.js"></script>
     <script>

+ 16 - 11
resources/views/admin/layouts.blade.php

@@ -2,6 +2,7 @@
 @section('title', sysConfig('website_name'))
 @section('layout_css')
     <link href="/assets/global/fonts/font-awesome/css/all.min.css" rel="stylesheet">
+    <link href="/assets/global/vendor/toastr/toastr.min.css" rel="stylesheet">
     @yield('css')
 @endsection
 @section('body_class', 'dashboard')
@@ -364,23 +365,27 @@
 @endsection
 @section('layout_javascript')
     <script src="/assets/custom/sweetalert2/sweetalert2.all.min.js"></script>
+    <script src="/assets/global/vendor/toastr/toastr.min.js"></script>
+    <script src="/assets/global/js/Plugin/toastr.js"></script>
     <script>
         // 全局变量,用于common.js
         const CSRF_TOKEN = '{{ csrf_token() }}';
-        const TRANS = {
-            warning: '{{ trans('common.warning') }}',
-            confirm: {
-                delete: '{{ trans('admin.confirm.delete', ['attribute' => '{attribute}', 'name' => '{name}']) }}'
+
+        // 页面特定的翻译文本
+        window.i18n.extend({
+            'warning': '{{ trans('common.warning') }}',
+            'confirm': {
+                'delete': '{{ trans('admin.confirm.delete', ['attribute' => '{attribute}', 'name' => '{name}']) }}'
             },
-            btn: {
-                close: '{{ trans('common.close') }}',
-                confirm: '{{ trans('common.confirm') }}'
+            'btn': {
+                'close': '{{ trans('common.close') }}',
+                'confirm': '{{ trans('common.confirm') }}'
             },
-            copy: {
-                success: '{{ trans('common.copy.success') }}',
-                failed: '{{ trans('common.copy.failed') }}'
+            'copy': {
+                'success': '{{ trans('common.copy.success') }}',
+                'failed': '{{ trans('common.copy.failed') }}'
             }
-        };
+        });
 
         const $buoop = {
             required: {

+ 21 - 86
resources/views/admin/node/index.blade.php

@@ -203,85 +203,26 @@
 @push('javascript')
     @vite(['resources/js/app.js'])
     <script>
+        window.i18n.extend({
+            'broadcast': {
+                'error': '{{ trans('common.error') }}',
+                'websocket_unavailable': '{{ trans('common.broadcast.websocket_unavailable') }}',
+                'websocket_disconnected': '{{ trans('common.broadcast.websocket_disconnected') }}',
+                'setup_failed': '{{ trans('common.broadcast.setup_failed') }}',
+                'disconnect_failed': '{{ trans('common.broadcast.disconnect_failed') }}'
+            }
+        });
         // 全局状态
         const state = {
             actionType: null, // 'check' | 'geo' | 'reload'
             actionId: null, // 当前操作针对的节点 id(null/'' 表示批量)
-            channel: null,
             results: {}, // 按 nodeId 存储节点信息与已收到的数据
             finished: {}, // 标记 nodeId 是否完成
-            spinnerFallbacks: {}, // 防止无限 spinner 的后备定时器
-            errorDisplayed: false
+            spinnerFallbacks: {} // 防止无限 spinner 的后备定时器
         };
 
         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;
-                }
-            }
-        };
-
         // 配置表:保留原按钮 id 规则 & 原模态结构
         const ACTION_CFG = {
             check: {
@@ -329,16 +270,6 @@
             }
         };
 
-        // 清理(仅用于开始新操作时)
-        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;
@@ -381,8 +312,7 @@
                 return;
             }
 
-            // 开始新操作:清理之前的连接/缓存(这是你希望的行为)
-            cleanupPreviousConnection();
+            // 开始新操作:清理之前的连接/缓存
             state.actionType = type;
             state.actionId = id;
             state.results = {};
@@ -394,9 +324,14 @@
             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) {
+            // 使用统一的广播管理器订阅频道
+            const success = window.broadcastingManager.subscribe(
+                channelName,
+                cfg.event,
+                (e) => handleResult(e.data || e, type, id, $btn)
+            );
+
+            if (!success) {
                 // 订阅失败:恢复按钮状态
                 setSpinner($btn, cfg.icon, false);
                 clearSpinnerFallback(fallbackKey);
@@ -412,8 +347,8 @@
                     // 不在此处处理最终结果,交由广播处理(避免 race)
                 },
                 error: function(xhr, status, error) {
-                    if (!Reverb.isConnected()) {
-                        Reverb.handleError('WebSocket is not available. Please make sure the Reverb server is running.');
+                    if (!window.broadcastingManager.isConnected()) {
+                        window.broadcastingManager.handleError(i18n('broadcast.websocket_unavailable'));
                     } else {
                         showMessage({
                             title: '{{ trans('common.error') }}',

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

@@ -2,7 +2,6 @@
 @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;
@@ -22,9 +21,7 @@
     <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() {

+ 125 - 20
resources/views/admin/user/index.blade.php

@@ -123,9 +123,23 @@
             </x-slot:tbody>
         </x-admin.table-panel>
     </div>
+
+    <!-- 用户VNet检测结果模态框 -->
+    <x-ui.modal id="userVNetCheckModal" :title="trans('admin.user.connection_test')" size="lg">
+    </x-ui.modal>
 @endsection
 @push('javascript')
+    @vite(['resources/js/app.js'])
     <script>
+        window.i18n.extend({
+            'broadcast': {
+                'error': '{{ trans('common.error') }}',
+                'websocket_unavailable': '{{ trans('common.broadcast.websocket_unavailable') }}',
+                'websocket_disconnected': '{{ trans('common.broadcast.websocket_disconnected') }}',
+                'setup_failed': '{{ trans('common.broadcast.setup_failed') }}',
+                'disconnect_failed': '{{ trans('common.broadcast.disconnect_failed') }}'
+            }
+        });
         @can('admin.user.batch')
             function batchAddUsers() { // 批量生成账号
                 showConfirm({
@@ -173,37 +187,128 @@
         @endcan
 
         @can('admin.user.VNetInfo')
+            // 全局状态
+            const userVNetState = {
+                results: {} // 按 nodeId 存储节点信息与已收到的数据
+            };
+
+            // 构建并显示模态框
+            function buildVNetCheckUI() {
+                const body = document.querySelector('#userVNetCheckModal .modal-body');
+                let html = `<table class="table table-hover">
+                            <thead>
+                                <tr>
+                                    <th>{{ trans('model.node.attribute') }}</th>
+                                    <th>{{ trans('common.status.attribute') }}</th>
+                                </tr>
+                            </thead>
+                            <tbody>`;
+
+                Object.keys(userVNetState.results).forEach(nodeId => {
+                    const node = userVNetState.results[nodeId];
+                    html += `
+                    <tr data-node-id="${nodeId}">
+                        <td>${node.name}</td>
+                        <td><i class="wb-loop icon-spin"></i></td>
+                    </tr>`;
+                });
+                html += '</tbody></table></div>';
+                body.innerHTML = html;
+            }
+
+            // 更新模态框中的节点状态
+            function updateVNetCheckUI(nodeId, available) {
+                try {
+                    const row = document.querySelector(`#userVNetCheckModal tr[data-node-id="${nodeId}"]`);
+                    if (!row) return;
+
+                    const statusEl = row.querySelector('td:nth-child(2)');
+                    if (statusEl) {
+                        statusEl.innerHTML = available ? '✔️' : '❌';
+                    }
+                } catch (e) {}
+            }
+
+            // 处理广播数据
+            function handleVNetResult(e) {
+                // 如果包含 nodeList:构建初始 UI 框架
+                if (e.data && e.data.nodeList) {
+                    $('#userVNetCheckModal').modal('show');
+                    userVNetState.results = {};
+
+                    Object.keys(e.data.nodeList).forEach(nodeId => {
+                        const nodeName = e.data.nodeList[nodeId];
+                        userVNetState.results[nodeId] = {
+                            name: nodeName,
+                            available: null // 检查中
+                        };
+                    });
+
+                    // 构建并显示 modal
+                    buildVNetCheckUI();
+                    return;
+                }
+
+                // 处理详细数据
+                try {
+                    const nodeId = e.data.nodeId;
+                    if (!nodeId || !userVNetState.results[nodeId]) return;
+
+                    userVNetState.results[nodeId].available = e.data.available;
+                    updateVNetCheckUI(nodeId, e.data.available);
+                } catch (err) {
+                    console.error('handleVNetResult error', err);
+                }
+            }
+
             function VNetInfo(id) { // 节点连通性测试
                 const $triggerElement = $(`#vent_${id}`);
+                const channelName = `user.check.${id}`;
+
+                // 清理之前的连接
+                window.broadcastingManager.unsubscribe(channelName);
+                userVNetState.results = {};
+
+                // 启动 spinner
+                $triggerElement.removeClass("wb-link-broken").addClass("wb-loop icon-spin");
 
+                // 使用统一的广播管理器订阅频道
+                const success = window.broadcastingManager.subscribe(
+                    channelName,
+                    '.user.vnet.tasks',
+                    (e) => handleVNetResult(e)
+                );
+
+                if (!success) {
+                    // 订阅失败:恢复按钮状态
+                    $triggerElement.removeClass("wb-loop icon-spin").addClass("wb-link-broken");
+                    return;
+                }
+
+                // 触发后端接口(Ajax)
                 ajaxPost(jsRoute('{{ route('admin.user.VNetInfo', 'PLACEHOLDER') }}', id), {}, {
+                    beforeSend: function() {
+                        // spinner 已经设置
+                    },
                     success: function(ret) {
-                        if (ret.status === "success") {
-                            let str = "";
-                            for (let i in ret.data) {
-                                str += "<tr><td>" + ret.data[i]["id"] + "</td><td>" + ret.data[i]["name"] + "</td><td>" +
-                                    ret.data[i]["avaliable"] + "</td></tr>";
-                            }
-                            showMessage({
-                                title: ret.title,
-                                html: '<table class="my-20"><thead class="thead-default"><tr><th> ID </th><th> {{ trans('model.node.attribute') }} </th> <th> {{ trans('common.status.attribute') }} </th></thead><tbody>' +
-                                    str + "</tbody></table>",
-                                icon: "info",
-                                showConfirmButton: false
-                            });
+                        // 不在此处处理最终结果,交由广播处理(避免 race)
+                    },
+                    error: function(xhr, status, error) {
+                        if (!window.broadcastingManager.isConnected()) {
+                            window.broadcastingManager.handleError(i18n('broadcast.websocket_unavailable'));
                         } else {
                             showMessage({
-                                title: ret.title,
-                                message: ret.data,
-                                icon: "error"
+                                title: '{{ trans('common.error') }}',
+                                message: `{{ trans('common.request_failed') }} ${error}: ${xhr?.responseJSON?.exception}`,
+                                icon: 'error',
+                                showConfirmButton: true
                             });
                         }
-                    },
-                    beforeSend: function() {
-                        $triggerElement.removeClass("wb-link-broken").addClass("wb-loop icon-spin");
+                        // 出错时恢复 spinner
+                        $triggerElement.removeClass("wb-loop icon-spin").addClass("wb-link-broken");
                     },
                     complete: function() {
-                        $triggerElement.removeClass("wb-loop icon-spin").addClass("wb-link-broken");
+                        // 不在这里恢复按钮状态,而是等所有广播完成后再恢复
                     }
                 });
             }

+ 28 - 36
resources/views/user/components/payment/default.blade.php

@@ -52,31 +52,21 @@
     @endif
 
     <script>
+        window.i18n.extend({
+            'broadcast': {
+                'error': '{{ trans('common.error') }}',
+                'websocket_unavailable': '{{ trans('common.broadcast.websocket_unavailable') }}',
+                'websocket_disconnected': '{{ trans('common.broadcast.websocket_disconnected') }}',
+                'setup_failed': '{{ trans('common.broadcast.setup_failed') }}',
+                'disconnect_failed': '{{ trans('common.broadcast.disconnect_failed') }}'
+            }
+        });
         @if (config('broadcasting.default') !== 'null')
             let pollingStarted = false
-            let pollingInterval = null
-
-            function clearAll() {
-                if (pollingInterval) {
-                    clearInterval(pollingInterval);
-                    pollingInterval = null
-                }
-            }
-
-            function disconnectEcho() {
-                try {
-                    if (typeof Echo !== 'undefined') {
-                        Echo.leave(`payment-status.{{ $payment->trade_no }}`)
-                        Echo.connector?.disconnect?.()
-                    }
-                } catch (e) {
-                    console.error('关闭 Echo 失败:', e)
-                }
-            }
 
             function onFinal(status, message) {
-                clearAll()
-                disconnectEcho()
+                window.broadcastingManager.stopPolling('payment-status'); // 停止轮询
+                window.broadcastingManager.disconnect(); // 断开连接
                 showMessage({
                     title: message,
                     icon: status === 'success' ? 'success' : 'error',
@@ -88,9 +78,10 @@
             function startPolling() {
                 if (pollingStarted) return
                 pollingStarted = true
-                disconnectEcho()
+                window.broadcastingManager.disconnect(); // 断开连接
 
-                pollingInterval = setInterval(() => {
+                // 使用统一的广播管理器启动轮询
+                window.broadcastingManager.startPolling('payment-status', () => {
                     ajaxGet('{{ route('orderStatus') }}', {
                         trade_no: '{{ $payment->trade_no }}'
                     }, {
@@ -105,26 +96,27 @@
             }
 
             function setupPaymentListener() {
-                if (typeof Echo === 'undefined' || typeof Pusher === 'undefined') {
+                // 使用统一的广播管理器检查 Echo 是否可用
+                if (!window.broadcastingManager.isEchoAvailable()) {
                     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) => {
+                try {
+                    // 使用统一的广播管理器订阅频道
+                    const success = window.broadcastingManager.subscribe(
+                        'payment-status.{{ $payment->trade_no }}',
+                        '.payment.status.updated',
+                        (e) => {
                             if (['success', 'error'].includes(e.status)) {
                                 onFinal(e.status, e.message)
                             }
-                        })
+                        }
+                    );
+
+                    if (!success) {
+                        startPolling()
+                    }
 
                 } catch (e) {
                     console.error('Echo 初始化失败:', e)

+ 12 - 9
resources/views/user/layouts.blade.php

@@ -162,17 +162,20 @@
     <script>
         // 全局变量,用于common.js
         const CSRF_TOKEN = '{{ csrf_token() }}';
-        const TRANS = {
-            warning: '{{ trans('common.warning') }}',
-            btn: {
-                close: '{{ trans('common.close') }}',
-                confirm: '{{ trans('common.confirm') }}'
+
+        // 页面特定的翻译文本
+        window.i18n.extend({
+            'warning': '{{ trans('common.warning') }}',
+            'btn': {
+                'close': '{{ trans('common.close') }}',
+                'confirm': '{{ trans('common.confirm') }}'
             },
-            copy: {
-                success: '{{ trans('common.copy.success') }}',
-                failed: '{{ trans('common.copy.failed') }}'
+            'copy': {
+                'success': '{{ trans('common.copy.success') }}',
+                'failed': '{{ trans('common.copy.failed') }}'
             }
-        };
+        });
+
         const $buoop = {
             required: {
                 e: 11,