Просмотр исходного кода

Add Broadcast Nodes with Segmented Processing

BrettonYe 1 неделя назад
Родитель
Сommit
99523ba4c0

+ 100 - 19
app/Http/Controllers/Admin/NodeController.php

@@ -7,7 +7,6 @@ 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;
@@ -15,6 +14,7 @@ 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;
@@ -68,19 +68,38 @@ class NodeController extends Controller
 
     public function store(NodeRequest $request): JsonResponse
     { // 添加节点
+        // 获取验证后的数据
+        $validatedData = $request->validated();
+
+        // 构建操作清单
+        $operationList = ['save_node_info', 'create_auth', 'sync_labels', 'refresh_geo'];
+
+        // 根据节点配置添加相应的操作项
+        if (! ($validatedData['is_ddns'] ?? false) && ($validatedData['server'] ?? false) && sysConfig('ddns_mode')) {
+            $operationList[] = 'handle_ddns';
+        }
+
+        // 发送操作清单
+        broadcast(new NodeActions('create', ['list' => $operationList]));
+
         try {
-            if ($node = Node::create($this->nodeStore($request->validated()))) {
+            // 保存节点信息
+            if ($node = Node::create($this->nodeStore($validatedData))) {
+                broadcast(new NodeActions('create', ['operation' => 'save_node_info', 'status' => 1]));
                 if ($request->has('labels')) { // 生成节点标签
                     $node->labels()->attach($request->input('labels'));
                 }
+                broadcast(new NodeActions('create', ['operation' => 'sync_labels', 'status' => 1]));
 
                 return response()->json(['status' => 'success', 'message' => trans('common.success_item', ['attribute' => trans('common.add')])]);
             }
         } catch (Exception $e) {
             Log::error(trans('common.error_action_item', ['action' => trans('common.add'), 'attribute' => trans('model.node.attribute')]).': '.$e->getMessage());
+            broadcast(new NodeActions('create', ['status' => 0, 'message' => $e->getMessage()]));
 
             return response()->json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('common.add')]).', '.$e->getMessage()]);
         }
+        broadcast(new NodeActions('create', ['status' => 0]));
 
         return response()->json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('common.add')])]);
     }
@@ -211,34 +230,78 @@ class NodeController extends Controller
 
     public function update(NodeRequest $request, Node $node): JsonResponse
     { // 编辑节点
+        // 获取验证后的数据
+        $validatedData = $request->validated();
+
+        // 构建操作清单
+        $operationList = ['save_node_info', 'sync_labels', 'refresh_geo']; // 操作清单
+
+        if (! ($validatedData['is_ddns'] ?? $node->is_ddns) && ($validatedData['server'] ?? $node->server) && sysConfig('ddns_mode')) { // 检查是否有DDNS相关变更
+            $operationList[] = 'handle_ddns';
+        }
+
+        if ((int) ($validatedData['type'] ?? $node->type) === 4) { // 检查是否是VNET节点(可能需要重新加载)
+            $operationList[] = 'reload_node';
+        }
+
+        // 发送操作清单
+        broadcast(new NodeActions('update', ['list' => $operationList], $node->id));
+
         try {
-            if ($node->update($this->nodeStore($request->validated()))) {
-                // 更新节点标签
+            // 先尝试更新节点信息
+            if ($node->update($this->nodeStore($validatedData))) {
+                broadcast(new NodeActions('update', ['operation' => 'save_node_info', 'status' => 1], $node->id));
+
+                // 如果没有字段变更,强制触发更新以确保 observer 被调用
+                if (empty($node->getChanges())) {
+                    $node->touch();
+                }
+
+                // 同步节点标签
                 $node->labels()->sync($request->input('labels'));
+                broadcast(new NodeActions('update', ['operation' => 'sync_labels', 'status' => 1], $node->id));
 
                 return response()->json(['status' => 'success', 'message' => trans('common.success_item', ['attribute' => trans('common.edit')])]);
             }
         } catch (Exception $e) {
             Log::error(trans('common.error_action_item', ['action' => trans('common.edit'), 'attribute' => trans('model.node.attribute')]).': '.$e->getMessage());
+            broadcast(new NodeActions('update', ['status' => 0, 'message' => $e->getMessage()], $node->id));
 
             return response()->json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('common.edit')]).', '.$e->getMessage()]);
         }
+        broadcast(new NodeActions('update', ['status' => 0], $node->id));
 
         return response()->json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('common.edit')])]);
     }
 
     public function destroy(Node $node): JsonResponse
     { // 删除节点
+        // 发送操作清单给前端
+        $operationList = ['delete_node'];
+
+        // 根据节点配置添加相应的操作项
+        if ($node->server && sysConfig('ddns_mode')) {
+            $operationList[] = 'handle_ddns';
+        }
+
+        broadcast(new NodeActions('delete', ['list' => $operationList], $node->id));
+
         try {
+            // 删除节点
             if ($node->delete()) {
+                broadcast(new NodeActions('delete', ['operation' => 'delete_node', 'status' => 1], $node->id));
+
                 return response()->json(['status' => 'success', 'message' => trans('common.success_item', ['attribute' => trans('common.delete')])]);
             }
         } catch (Exception $e) {
             Log::error(trans('common.error_action_item', ['action' => trans('common.delete'), 'attribute' => trans('model.node.attribute')]).': '.$e->getMessage());
+            broadcast(new NodeActions('delete', ['status' => 0, 'message' => $e->getMessage()], $node->id));
 
             return response()->json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('common.delete')]).', '.$e->getMessage()]);
         }
 
+        broadcast(new NodeActions('delete', ['status' => 0], $node->id));
+
         return response()->json(['status' => 'fail', 'message' => trans('common.failed_item', ['attribute' => trans('common.delete')])]);
     }
 
@@ -247,22 +310,35 @@ class NodeController extends Controller
         // 获取节点集合并预加载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;
+            return ['node' => $n, 'ips' => $n->ips()];
         });
 
         // 构建节点列表信息
-        $nodeList = $nodes->mapWithKeys(function ($n) {
-            return [$n->id => ['name' => $n->name, 'ips' => $n->ips]];
+        $nodeList = $nodes->mapWithKeys(function ($item) {
+            return [$item['node']->id => ['name' => $item['node']->name, 'ips' => $item['ips']]];
         })->toArray();
 
         // 立即发送节点列表信息给前端
-        broadcast(new NodeActions('check', ['nodeList' => $nodeList], $node?->id));
+        broadcast(new NodeActions('check', ['list' => $nodeList], $node?->id));
 
         // 异步分发检测任务,提高响应速度
-        $nodes->each(function ($n) use ($node) {
-            dispatch(new CheckNodeIp($n->id, $n->ips, $n->port ?? 22, $node?->id));
+        $nodes->each(function ($item) use ($node) {
+            dispatch(static function () use ($item, $node) {
+                foreach ($item['ips'] as $ip) {
+                    $ret = ['ip' => $ip, 'icmp' => 4, 'tcp' => 4, 'node_id' => $item['node']->id, 'status' => 1];
+                    try {
+                        $status = NetworkDetection::networkStatus($ip, $item['node']->port ?? 22);
+                        $ret['icmp'] = $status['icmp'];
+                        $ret['tcp'] = $status['tcp'];
+                    } catch (Exception $e) {
+                        Log::error("节点 [{$item['node']->id}] IP [$ip] 检测失败: ".$e->getMessage());
+                        $ret += ['message' => $e->getMessage()];
+                        $ret['status'] = 0;
+                    }
+
+                    broadcast(new NodeActions('check', $ret, $node?->id));
+                }
+            });
         });
 
         return response()->json([
@@ -278,17 +354,18 @@ class NodeController extends Controller
         $nodes = $node ? collect([$node]) : Node::whereStatus(1)->get();
 
         // 发送节点列表信息
-        broadcast(new NodeActions('geo', ['nodeList' => $nodes->pluck('name', 'id')], $node?->id));
+        broadcast(new NodeActions('geo', ['list' => $nodes->pluck('name', 'id')], $node?->id));
 
         // 异步处理地理位置刷新
         $nodes->each(function ($n) use ($node) {
             dispatch(static function () use ($n, $node) {
-                $ret = ['nodeId' => $n->id];
+                $ret = ['node_id' => $n->id, 'status' => 1];
                 try {
                     $ret += $n->refresh_geo();
                 } catch (Exception $e) {
                     Log::error("节点 [{$n->id}] 刷新地理位置失败: ".$e->getMessage());
-                    $ret += ['error' => $e->getMessage()];
+                    $ret += ['message' => $e->getMessage()];
+                    $ret['status'] = 0;
                 }
 
                 broadcast(new NodeActions('geo', $ret, $node?->id));
@@ -308,17 +385,21 @@ class NodeController extends Controller
         $nodes = $node ? collect([$node]) : Node::whereStatus(1)->whereType(4)->get();
 
         // 发送节点列表信息
-        broadcast(new NodeActions('reload', ['nodeList' => $nodes->pluck('name', 'id')], $node?->id));
+        broadcast(new NodeActions('reload', ['list' => $nodes->pluck('name', 'id')], $node?->id));
 
         // 异步处理节点重载
         $nodes->each(function ($n) use ($node) {
             dispatch(static function () use ($n, $node) {
-                $ret = ['nodeId' => $n->id];
+                $ret = ['node_id' => $n->id, 'status' => 1];
                 try {
-                    $ret += (new ReloadNode($n))->handle();
+                    $ret = array_merge($ret, (new ReloadNode($n))->handle());
+                    if (count($ret['error'] ?? [])) {
+                        $ret['status'] = 0;
+                    }
                 } catch (Exception $e) {
                     Log::error("节点 [{$n->id}] 重载失败: ".$e->getMessage());
-                    $ret += ['error' => $e->getMessage()];
+                    $ret['message'] = $e->getMessage();
+                    $ret['status'] = 0;
                 }
 
                 broadcast(new NodeActions('reload', $ret, $node?->id));

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

@@ -1,42 +0,0 @@
-<?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));
-        }
-    }
-}

+ 10 - 6
app/Jobs/VNet/reloadNode.php

@@ -35,28 +35,32 @@ class ReloadNode implements ShouldQueue
 
     public function handle(): array
     {
+        $result = ['error' => [], 'success' => []];
+
         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['error'] = [$node->server];
+                    $result['error'][] = $node->server;
+                } else {
+                    $result['success'][] = $node->server;
                 }
             } else { // 多IP支持
-                $result = ['list' => $node->ips()];
-                foreach ($result['list'] as $ip) {
+                foreach ($node->ips() as $ip) {
                     if (! $this->send($ip.':'.$node->push_port, $node->auth->secret, $data)) {
                         $result['error'][] = $ip;
+                    } else {
+                        $result['success'][] = $ip;
                     }
                 }
             }
         }
 
-        return $result ?? [];
+        return $result;
     }
 
-    public function send(string $host, string $secret, array $data): bool
+    private function send(string $host, string $secret, array $data): bool
     {
         try {
             $response = Http::baseUrl($host)->timeout(15)->withHeader('secret', $secret)->post('api/v2/node/reload', $data);

+ 2 - 4
app/Models/Node.php

@@ -144,9 +144,7 @@ class Node extends Model
             $data = IP::getIPGeo($ip[0]); // 复数IP都以第一个为准
 
             if ($data) {
-                self::withoutEvents(function () use ($data) {
-                    $this->update(['geo' => ($data['latitude'] ?? null).','.($data['longitude'] ?? null)]);
-                });
+                $this->updateQuietly(['geo' => ($data['latitude'] ?? null).','.($data['longitude'] ?? null)]);
                 $ret['update'] = [$data['latitude'] ?? null, $data['longitude'] ?? null];
             }
         }
@@ -167,7 +165,7 @@ class Node extends Model
             $ip = $type === 4 ? $this->ip : $this->ipv6; // check the multiple existing of ip
         }
 
-        return array_map('trim', explode(',', $ip));
+        return $ip ? array_map('trim', explode(',', $ip)) : [];
     }
 
     public function getSSRConfig(): array

+ 115 - 42
app/Observers/NodeObserver.php

@@ -2,91 +2,164 @@
 
 namespace App\Observers;
 
+use App\Events\NodeActions;
 use App\Jobs\VNet\ReloadNode;
 use App\Models\Node;
 use App\Utils\DDNS;
 use Arr;
-use Log;
+use Exception;
 use Str;
 
 class NodeObserver
 {
-    public function saved(Node $node): void
+    // 辅助方法:发送广播消息
+    private function broadcastMessage(string $type, array $data, ?int $nodeId = null): void
     {
-        $node->refresh_geo();
+        broadcast(new NodeActions($type, $data, $nodeId));
+    }
+
+    // 辅助方法:处理DDNS操作并发送广播
+    private function handleDdnsOperation(string $type, DDNS $dns, string $operation, string $ip = '', string $recordType = '', ?int $nodeId = null): void
+    {
+        try {
+            if ($operation === 'store') {
+                $dns->store($ip, $recordType);
+            } elseif ($operation === 'destroy') {
+                $dns->destroy($recordType, $ip);
+            }
+
+            $this->broadcastMessage($type, ['operation' => 'handle_ddns', 'sub_operation' => $operation, 'data' => $ip, 'status' => 1], $nodeId);
+        } catch (Exception $e) {
+            $this->broadcastMessage($type, ['operation' => 'handle_ddns', 'sub_operation' => $operation, 'data' => $ip, 'status' => 0, 'message' => $e->getMessage()], $nodeId);
+        }
+    }
+
+    // 处理IP变化的辅助方法
+    private function updateIpChanges(DDNS $dns, string $originalIps, array $currentIps, int $nodeId, string $recordType = 'A'): void
+    {
+        $originalIpsArray = array_filter(array_map('trim', explode(',', $originalIps)));
+        // 计算需要删除的IP (在原列表但不在新列表中)
+        $ipsToDelete = array_diff($originalIpsArray, $currentIps);
+        // 计算需要添加的IP (在新列表但不在原列表中)
+        $ipsToAdd = array_diff($currentIps, $originalIpsArray);
+
+        $this->broadcastMessage('update', ['operation' => 'handle_ddns', 'sub_operation' => 'list', 'delete' => array_values($ipsToDelete), 'add' => array_values($ipsToAdd), 'status' => 1], $nodeId);
+
+        foreach ($ipsToDelete as $ip) {
+            $this->handleDdnsOperation('update', $dns, 'destroy', $ip, $recordType, $nodeId);
+        }
+
+        foreach ($ipsToAdd as $ip) {
+            $this->handleDdnsOperation('update', $dns, 'store', $ip, $recordType, $nodeId);
+        }
     }
 
     public function created(Node $node): void
     {
-        if (! $node->auth()->create(['key' => Str::random(), 'secret' => Str::random(8)])) {
-            Log::error('节点生成-自动生成授权时出现错误,请稍后自行生成授权!');
+        // GEO
+        $geo = $node->refresh_geo();
+        if (isset($geo['update'])) {
+            $this->broadcastMessage('create', ['operation' => 'refresh_geo', 'status' => 1]);
+        } else {
+            $this->broadcastMessage('create', ['operation' => 'refresh_geo', 'status' => 0]);
+        }
+
+        if ($node->auth()->create(['key' => Str::random(), 'secret' => Str::random(8)])) {
+            $this->broadcastMessage('create', ['operation' => 'create_auth', 'status' => 1]);
+        } else {
+            $this->broadcastMessage('create', ['operation' => 'create_auth', 'status' => 0, 'message' => trans('admin.node.operation.auth_failed')]);
         }
 
         if (! $node->is_ddns && $node->server && sysConfig('ddns_mode')) {
-            $newDNS = new DDNS($node->server);
-            if ($node->ip) {
-                foreach ($node->ips() as $ip) {
-                    $newDNS->store($ip);
-                }
+            $currentDNS = new DDNS($node->server);
+            $ips4 = $node->ips();
+            $ips6 = $node->ips(6);
+
+            // 发送DDNS操作开始信号及IP列表
+            $this->broadcastMessage('create', ['operation' => 'handle_ddns', 'sub_operation' => 'list', 'add' => array_merge($ips4, $ips6), 'status' => 1]);
+
+            // 处理IPv4地址
+            foreach ($ips4 as $ip) {
+                $this->handleDdnsOperation('create', $currentDNS, 'store', $ip, 'A');
             }
-            if ($node->ipv6) {
-                foreach ($node->ips(6) as $ip) {
-                    $newDNS->store($ip, 'AAAA');
-                }
+
+            // 处理IPv6地址
+            foreach ($ips6 as $ip) {
+                $this->handleDdnsOperation('create', $currentDNS, 'store', $ip, 'AAAA');
             }
         }
     }
 
     public function updated(Node $node): void
     {
+        // 在任何可能修改模型的操作之前保存原始值
+        $originalServer = $node->getOriginal('server');
+        $originalIp = $node->getOriginal('ip') ?? '';
+        $originalIpv6 = $node->getOriginal('ipv6') ?? '';
+
+        // GEO
+        $geo = $node->refresh_geo();
+        if (isset($geo['update'])) {
+            $this->broadcastMessage('update', ['operation' => 'refresh_geo', 'status' => 1], $node->id);
+        } else {
+            $this->broadcastMessage('update', ['operation' => 'refresh_geo', 'status' => 0], $node->id);
+        }
+
+        // DDNS
         if (! $node->is_ddns && sysConfig('ddns_mode')) {
             $changes = $node->getChanges();
+
             if (Arr::hasAny($changes, ['ip', 'ipv6', 'server'])) {
-                $newDNS = new DDNS($node->server);
-                if (Arr::has($changes, 'server')) { // 域名变动
-                    if ($node->getOriginal('server')) {
-                        (new DDNS($node->getOriginal('server')))->destroy(); // 删除原域名
-                    }
-                    if ($node->ip) { // 添加IPV4至新域名
-                        foreach ($node->ips() as $ip) {
-                            $newDNS->store($ip);
+                $currentDNS = new DDNS($node->server);
+
+                if (Arr::has($changes, 'server')) {
+                    $this->broadcastMessage('update', ['operation' => 'handle_ddns', 'sub_operation' => 'list', 'delete' => [$originalServer], 'add' => array_merge($node->ips(), $node->ips(6)), 'status' => 1], $node->id);
+                    if ($originalServer) {
+                        try {
+                            (new DDNS($originalServer))->destroy();
+                            $this->broadcastMessage('update', ['operation' => 'handle_ddns', 'sub_operation' => 'destroy', 'data' => $originalServer, 'status' => 1], $node->id);
+                        } catch (Exception $e) {
+                            $this->broadcastMessage('update', ['operation' => 'handle_ddns', 'sub_operation' => 'destroy', 'data' => $originalServer, 'status' => 0, 'message' => $e->getMessage()], $node->id);
                         }
                     }
-                    if ($node->ipv6) { // 添加IPV6至新域名
-                        foreach ($node->ips(6) as $ip) {
-                            $newDNS->store($ip, 'AAAA');
-                        }
+                    foreach ($node->ips() as $ip) {
+                        $this->handleDdnsOperation('update', $currentDNS, 'store', $ip, 'A', $node->id);
                     }
-                } else {// 域名未改动
-                    if (Arr::has($changes, 'ip')) { // IPV4变动
-                        $newDNS->destroy('A');
-                        if ($node->ip) { // 非空值 重新设置IPV4
-                            foreach ($node->ips() as $ip) {
-                                $newDNS->store($ip);
-                            }
-                        }
+                    foreach ($node->ips(6) as $ip) {
+                        $this->handleDdnsOperation('update', $currentDNS, 'store', $ip, 'AAAA', $node->id);
                     }
-                    if (Arr::has($changes, 'ipv6')) { // IPV6变动
-                        $newDNS->destroy('AAAA');
-                        if ($node->ipv6) { // 非空值 重新设置IPV6
-                            foreach ($node->ips(6) as $ip) {
-                                $newDNS->store($ip, 'AAAA');
-                            }
-                        }
+                } else {
+                    if (Arr::has($changes, 'ip')) {
+                        $this->updateIpChanges($currentDNS, $originalIp, $node->ips(), $node->id);
+                    }
+
+                    if (Arr::has($changes, 'ipv6')) {
+                        $this->updateIpChanges($currentDNS, $originalIpv6, $node->ips(6), $node->id, 'AAAA');
                     }
                 }
+            } else {
+                $this->broadcastMessage('update', ['operation' => 'handle_ddns', 'status' => 1, 'sub_operation' => 'unchanged'], $node->id);
             }
         }
 
+        // Reload
         if ((int) $node->type === 4) {
             ReloadNode::dispatch($node);
+            $this->broadcastMessage('update', ['operation' => 'reload_node', 'status' => 1], $node->id);
         }
     }
 
     public function deleted(Node $node): void
     {
+        // 发送删除DDNS操作开始信号
         if ($node->server && sysConfig('ddns_mode')) {
-            (new DDNS($node->server))->destroy();
+            $this->broadcastMessage('delete', ['operation' => 'handle_ddns', 'sub_operation' => 'list', 'delete' => [$node->server], 'status' => 1], $node->id);
+            try {
+                (new DDNS($node->server))->destroy();
+                $this->broadcastMessage('delete', ['operation' => 'handle_ddns', 'sub_operation' => 'destroy', 'data' => $node->server, 'status' => 1], $node->id);
+            } catch (Exception $e) {
+                $this->broadcastMessage('delete', ['operation' => 'handle_ddns', 'sub_operation' => 'destroy', 'data' => $node->server, 'status' => 0, 'message' => $e->getMessage()], $node->id);
+            }
         }
     }
 }

+ 2 - 2
package.json

@@ -4,7 +4,7 @@
   "scripts": {
     "dev": "vite",
     "build": "vite build",
-    "format": "prettier --write resources/views/**/*.blade.php"
+    "format": "npx prettier --write resources/views/**/*.blade.php"
   },
   "devDependencies": {
     "@shufo/prettier-plugin-blade": "^1.14.1",
@@ -13,6 +13,6 @@
     "laravel-vite-plugin": "^2.0.1",
     "prettier": "^3.3.3",
     "pusher-js": "^8.4.0",
-    "vite": "^7.1.7"
+    "vite": "^7.1.11"
   }
 }

+ 6 - 12
public/assets/js/config/common.js

@@ -120,12 +120,6 @@ function showConfirm(options) {
         ...options
     };
 
-    alertOptions.title = alertOptions.title || i18n('confirm_title');
-
-    if (!alertOptions.html && !alertOptions.text) {
-        alertOptions.text = i18n('confirm_action');
-    }
-
     showAlert(alertOptions).then((result) => {
         if (result.value && typeof onConfirm === "function") {
             onConfirm(result);
@@ -227,7 +221,7 @@ function handleErrors(xhr, options = {}) {
                     }
                 } else {
                     // 如果没有提供 form,回退到 swal 显示
-                    showMessage({title: xhr.responseJSON.message || i18n('operation_failed'), html: buildErrorHtml(errors), icon: "error"});
+                    showMessage({title: xhr.responseJSON.message, html: buildErrorHtml(errors), icon: "error"});
                 }
                 break;
 
@@ -235,14 +229,14 @@ function handleErrors(xhr, options = {}) {
                 if (settings.element) {
                     $(settings.element).html(buildErrorHtml(errors)).show();
                 } else {
-                    showMessage({title: xhr.responseJSON.message || i18n('operation_failed'), html: buildErrorHtml(errors), icon: "error"});
+                    showMessage({title: xhr.responseJSON.message, html: buildErrorHtml(errors), icon: "error"});
                 }
                 break;
 
             case 'swal':
             default:
                 showMessage({
-                    title: xhr.responseJSON.message || i18n('operation_failed'),
+                    title: xhr.responseJSON.message,
                     html: buildErrorHtml(errors),
                     icon: "error"
                 });
@@ -252,7 +246,7 @@ function handleErrors(xhr, options = {}) {
     }
 
     // 其它错误
-    const errorMessage = xhr.responseJSON?.message || xhr.statusText || i18n('request_failed');
+    const errorMessage = xhr.responseJSON?.message || xhr.statusText;
     
     // 提取公共的 showMessage 调用
     const showMessageOptions = {title: errorMessage, icon: "error"};
@@ -314,7 +308,7 @@ function handleResponse(response, options = {}) {
 
         if (settings.showMessage) {
             showMessage({
-                title: response.message || i18n('operation_success'),
+                title: response.message,
                 icon: "success",
                 showConfirmButton: false,
                 callback: successCallback
@@ -329,7 +323,7 @@ function handleResponse(response, options = {}) {
 
         if (settings.showMessage) {
             showMessage({
-                title: response.message || i18n('operation_failed'),
+                html: response.message,
                 icon: "error",
                 showConfirmButton: true,
                 callback: errorCallback

+ 73 - 33
resources/js/broadcastingManager.js

@@ -8,7 +8,7 @@ class BroadcastingManager {
         this.channels = new Map();
         this.pollingIntervals = new Map();
         this.errorDisplayed = false;
-        this.connectionState = 'unknown';
+        this.connectionState = "unknown";
     }
 
     /**
@@ -16,7 +16,7 @@ class BroadcastingManager {
      * @returns {boolean}
      */
     isEchoAvailable() {
-        return typeof Echo !== 'undefined' && Echo !== null;
+        return typeof Echo !== "undefined" && Echo !== null;
     }
 
     /**
@@ -25,12 +25,12 @@ class BroadcastingManager {
      */
     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;
+        return state === "connected" || state === "open" || state === 1;
     }
 
     /**
@@ -39,21 +39,23 @@ class BroadcastingManager {
      */
     getConnection() {
         if (!this.isEchoAvailable()) return null;
-        return Echo.connector?.pusher?.connection || Echo.connector?.socket || null;
+        return (
+            Echo.connector?.pusher?.connection || Echo.connector?.socket || null
+        );
     }
 
     /**
      * 显示错误信息
-     * @param {string} message 
+     * @param {string} message
      */
     handleError(message) {
         if (!this.errorDisplayed && !this.isConnected()) {
-            if (typeof showMessage !== 'undefined') {
+            if (typeof showMessage !== "undefined") {
                 showMessage({
-                    title: i18n('broadcast.error'),
+                    title: i18n("broadcast.error"),
                     message: message,
-                    icon: 'error',
-                    showConfirmButton: true
+                    icon: "error",
+                    showConfirmButton: true,
                 });
             } else {
                 console.error(message);
@@ -77,36 +79,39 @@ class BroadcastingManager {
      * @returns {boolean} 是否订阅成功
      */
     subscribe(channelName, event, handler) {
-        // 清理同名频道(如果存在)
+        // 清理同名频道(如果存在)- 确保彻底清除旧监听器
         this.unsubscribe(channelName);
-        
+
         if (!this.isEchoAvailable()) {
-            this.handleError(i18n('broadcast.websocket_unavailable'));
+            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';
+                conn.bind("connected", () => {
+                    this.connectionState = "connected";
                     this.clearError();
                 });
-                conn.bind('disconnected', () => {
-                    this.connectionState = 'disconnected';
-                    this.handleError(i18n('broadcast.websocket_disconnected'));
+                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}`);
+                this.handleError(
+                    `${i18n("broadcast.setup_failed")}: ${e?.message || e}`,
+                );
             }
             return false;
         }
@@ -114,21 +119,56 @@ class BroadcastingManager {
 
     /**
      * 取消订阅频道
-     * @param {string} channelName 
+     * @param {string} channelName
      */
     unsubscribe(channelName) {
         if (this.channels.has(channelName)) {
             try {
-                const channel = this.channels.get(channelName);
-                channel.stopListening();
-                Echo.leave(channelName);
+                // Laravel Echo 官方推荐方式:直接调用 Echo.leave()
+                // 它会自动清除频道对象、所有监听器和内部缓存
+                if (typeof Echo.leave === "function") {
+                    Echo.leave(channelName);
+                }
             } catch (e) {
-                // 忽略错误
+                console.warn(`Failed to unsubscribe from ${channelName}:`, e);
             }
+
             this.channels.delete(channelName);
         }
     }
 
+    /**
+     * 处理 AJAX 请求的错误 - 统一错误处理逻辑
+     * @param {string} title - 错误标题
+     * @param {string} message - 错误消息
+     */
+    handleAjaxError(title = null, message = null) {
+        if (!this.isConnected()) {
+            this.handleError(i18n("broadcast.websocket_unavailable"));
+        } else if (message || title) {
+            if (typeof showMessage !== "undefined") {
+                showMessage({
+                    title: title || i18n("common.error"),
+                    message: message,
+                    icon: "error",
+                    showConfirmButton: true,
+                });
+            } else {
+                console.error(title, message);
+            }
+        }
+    }
+
+    /**
+     * 生成频道名称
+     * @param {string} type - 频道类型
+     * @param {string|number} id - 资源 ID(可选)
+     * @returns {string} 频道名称
+     */
+    getChannelName(type, id = null) {
+        return id ? `${type}.${id}` : `${type}.all`;
+    }
+
     /**
      * 清理所有频道
      */
@@ -154,7 +194,7 @@ class BroadcastingManager {
 
     /**
      * 停止轮询
-     * @param {string} intervalId 
+     * @param {string} intervalId
      */
     stopPolling(intervalId) {
         if (this.pollingIntervals.has(intervalId)) {
@@ -182,10 +222,10 @@ class BroadcastingManager {
                 Echo.connector?.disconnect?.();
             }
         } catch (e) {
-            console.error(i18n('broadcast.disconnect_failed'), e);
+            console.error(i18n("broadcast.disconnect_failed"), e);
         }
     }
-    
+
     /**
      * 等待连接建立
      * @param {number} timeout - 超时时间(毫秒)
@@ -197,7 +237,7 @@ class BroadcastingManager {
                 resolve(true);
                 return;
             }
-            
+
             const startTime = Date.now();
             const checkConnection = () => {
                 if (this.isConnected()) {
@@ -208,7 +248,7 @@ class BroadcastingManager {
                     setTimeout(checkConnection, 100);
                 }
             };
-            
+
             checkConnection();
         });
     }
@@ -216,4 +256,4 @@ class BroadcastingManager {
 
 // 导出单例
 const broadcastingManager = new BroadcastingManager();
-export default broadcastingManager;
+export default broadcastingManager;

+ 16 - 0
resources/lang/de/admin.php

@@ -329,6 +329,22 @@ return [
             'v2_net_hint' => '⚠️ WebSocket-Übertragung erfordert TLS-Aktivierung',
             'v2_tls_provider_hint' => 'Backend-Unterschiede Erklärung:',
         ],
+        'create_operations' => 'Knoten Erstellungsoperationen',
+        'update_operations' => 'Knoten Aktualisierungsoperationen',
+        'delete_operations' => 'Knoten Löschoperationen',
+        'operation' => [
+            'auth_failed' => 'Erstellung der Knotenautorisierung fehlgeschlagen',
+            'create_auth' => 'Knotenautorisierung erstellen',
+            'delete_node' => 'Knoten löschen',
+            'handle_ddns' => 'DDNS-Einträge verwalten',
+            'reload_node' => 'Knoten neu laden',
+            'save_node_info' => 'Knoteninformationen speichern',
+            'store_domain_record' => 'DDNS-Eintrag speichern',
+            'sync_labels' => 'Labels synchronisieren',
+            'delete_domain_record' => 'DDNS-Eintrag löschen',
+            'unchanged' => 'Unverändert',
+            'refresh_geo' => 'Geolokalisierungsinformationen aktualisieren',
+        ],
         'proxy_info' => '*SS-Protokoll-Kompatibilitätserklärung',
         'proxy_info_hint' => 'Kompatibilitätsmodus erfordert Hinzufügung von <span class="red-700">_compatible</span> zum Backend-Konfigurationsnamen',
         'refresh_geo' => 'Geolokation aktualisieren',

+ 1 - 1
resources/lang/de/model.php

@@ -94,8 +94,8 @@ return [
         'relay_port' => 'Relay-Port',
         'renewal_cost' => 'Verlängerungsgebühr',
         'service_port' => 'Service-Port',
+        'service_password' => 'Dienstpasswort',
         'single' => 'Einzelport-Modus',
-        'single_passwd' => 'Einzelport-Passwort',
         'static' => 'Online-Status',
         'subscription_term' => 'Abonnement-Laufzeit',
         'traffic_limit' => 'Traffic-Obergrenze',

+ 16 - 0
resources/lang/en/admin.php

@@ -329,6 +329,22 @@ return [
             'v2_net_hint' => '⚠️ WebSocket requires TLS',
             'v2_tls_provider_hint' => 'Different backends have different configurations:',
         ],
+        'create_operations' => 'Node Creation Operations',
+        'update_operations' => 'Node Update Operations',
+        'delete_operations' => 'Node Deletion Operations',
+        'operation' => [
+            'auth_failed' => 'Failed to Create Node Authorization',
+            'create_auth' => 'Create Node Authorization',
+            'delete_node' => 'Delete Node',
+            'handle_ddns' => 'Handle DDNS Records',
+            'reload_node' => 'Reload Node',
+            'save_node_info' => 'Save Node Information',
+            'store_domain_record' => 'Store DDNS Records',
+            'sync_labels' => 'Synchronize Labels',
+            'delete_domain_record' => 'Delete DDNS Records',
+            'unchanged' => 'Unchanged',
+            'refresh_geo' => 'Update Geolocation Information',
+        ],
         'proxy_info' => '*SS protocol compatibility',
         'proxy_info_hint' => 'Compatibility mode requires adding <span class="red-700">_compatible</span> to backend config',
         'refresh_geo' => 'Refresh geolocation',

+ 1 - 1
resources/lang/en/model.php

@@ -94,8 +94,8 @@ return [
         'relay_port' => 'Relay Port',
         'renewal_cost' => 'Renewal Fee',
         'service_port' => 'Service Port',
+        'service_password' => 'Service Password',
         'single' => 'Single Port Mode',
-        'single_passwd' => 'Single Port Password',
         'static' => 'Online Status',
         'subscription_term' => 'Subscription Term',
         'traffic_limit' => 'Traffic Cap',

+ 16 - 0
resources/lang/fa/admin.php

@@ -329,6 +329,22 @@ return [
             'v2_net_hint' => '⚠️ WebSocket نیازمند رمزگذاری TLS است',
             'v2_tls_provider_hint' => 'بک‌اندهای مختلف پیکربندی‌های متفاوتی دارند:',
         ],
+        'create_operations' => 'عملیات ایجاد گره',
+        'update_operations' => 'عملیات به‌روزرسانی گره',
+        'delete_operations' => 'عملیات حذف گره',
+        'operation' => [
+            'auth_failed' => 'ایجاد مجوز گره ناموفق بود',
+            'create_auth' => 'ایجاد مجوز گره',
+            'delete_node' => 'حذف گره',
+            'handle_ddns' => 'مدیریت رکوردهای DDNS',
+            'reload_node' => 'بارگذاری مجدد گره',
+            'save_node_info' => 'ذخیره اطلاعات گره',
+            'store_domain_record' => 'ذخیره رکورد DDNS',
+            'sync_labels' => 'همگام‌سازی برچسب‌ها',
+            'delete_domain_record' => 'حذف رکورد DDNS',
+            'unchanged' => 'بدون تغییر',
+            'refresh_geo' => 'به‌روزرسانی اطلاعات جغرافیایی',
+        ],
         'proxy_info' => '*سازگاری پروتکل SS',
         'proxy_info_hint' => 'حالت سازگاری نیاز به افزودن <span class="red-700">_compatible</span> به پیکربندی بک‌اند دارد',
         'refresh_geo' => 'تازه‌سازی موقعیت جغرافیایی',

+ 1 - 1
resources/lang/fa/model.php

@@ -94,8 +94,8 @@ return [
         'relay_port' => 'پورت relay',
         'renewal_cost' => 'هزینه تمدید',
         'service_port' => 'پورت سرویس',
+        'service_password' => 'رمز عبور سرویس',
         'single' => 'تک پورت',
-        'single_passwd' => 'رمز عبور تک پورت',
         'static' => 'وضعیت اجرا',
         'subscription_term' => 'دوره اشتراک',
         'traffic_limit' => 'محدودیت ترافیک',

+ 16 - 0
resources/lang/ja/admin.php

@@ -329,6 +329,22 @@ return [
             'v2_net_hint' => '⚠️ WebSocket転送にはTLS有効化が必要',
             'v2_tls_provider_hint' => 'バックエンド差異説明:',
         ],
+        'create_operations' => 'ノード作成操作',
+        'update_operations' => 'ノード作成操作',
+        'delete_operations' => 'ノード削除操作',
+        'operation' => [
+            'auth_failed' => 'ノード認証の作成に失敗しました',
+            'create_auth' => 'ノード認証を作成',
+            'delete_node' => 'ノードを削除',
+            'handle_ddns' => 'DDNSレコードを処理',
+            'reload_node' => 'ノードを再読み込み',
+            'save_node_info' => 'ノード情報を保存',
+            'store_domain_record' => 'DDNSレコードを保存',
+            'sync_labels' => 'ラベルを同期',
+            'delete_domain_record' => 'DDNSレコードを削除',
+            'unchanged' => '変更なし',
+            'refresh_geo' => '位置情報を更新',
+        ],
         'proxy_info' => '*SSプロトコル互換性説明',
         'proxy_info_hint' => '互換モードはバックエンド設定名に<span class="red-700">_compatible</span>の追加が必要',
         'refresh_geo' => '地理位置情報更新',

+ 1 - 1
resources/lang/ja/model.php

@@ -94,8 +94,8 @@ return [
         'relay_port' => '中継ポート',
         'renewal_cost' => '更新料金',
         'service_port' => 'サービスポート',
+        'service_password' => 'サービスパスワード',
         'single' => 'シングルポート',
-        'single_passwd' => 'シングルポートパスワード',
         'static' => '稼働状態',
         'subscription_term' => 'サブスクリプション期間',
         'traffic_limit' => 'トラフィック制限',

+ 16 - 0
resources/lang/ko/admin.php

@@ -329,6 +329,22 @@ return [
             'v2_net_hint' => '⚠️ WebSocket 전송에는 TLS 활성화 필요',
             'v2_tls_provider_hint' => '백엔드 차이 설명:',
         ],
+        'create_operations' => '노드 생성 작업',
+        'update_operations' => '노드 업데이트 작업',
+        'delete_operations' => '노드 삭제 작업',
+        'operation' => [
+            'auth_failed' => '노드 인증 생성 실패',
+            'create_auth' => '노드 인증 생성 실패',
+            'delete_node' => '노드 삭제',
+            'handle_ddns' => 'DDNS 레코드 처리',
+            'reload_node' => '노드 재로드',
+            'save_node_info' => '노드 정보 저장',
+            'store_domain_record' => 'DDNS 레코드 저장',
+            'sync_labels' => '라벨 동기화',
+            'delete_domain_record' => 'DDNS 레코드 삭제',
+            'unchanged' => '변경 없음',
+            'refresh_geo' => '지리 위치 정보 업데이트',
+        ],
         'proxy_info' => '*SS 프로토콜 호환성 설명',
         'proxy_info_hint' => '호환 모드는 백엔드 설정명에 <span class="red-700">_compatible</span> 추가 필요',
         'refresh_geo' => '지리 위치 정보 업데이트',

+ 1 - 1
resources/lang/ko/model.php

@@ -94,8 +94,8 @@ return [
         'relay_port' => '중계 포트',
         'renewal_cost' => '갱신 요금',
         'service_port' => '서비스 포트',
+        'service_password' => '서비스 비밀번호',
         'single' => '싱글 포트',
-        'single_passwd' => '싱글 포트 비밀번호',
         'static' => '가동 상태',
         'subscription_term' => '구독 기간',
         'traffic_limit' => '트래픽 제한',

+ 16 - 0
resources/lang/ru/admin.php

@@ -329,6 +329,22 @@ return [
             'v2_net_hint' => '⚠️ WebSocket требует включения TLS',
             'v2_tls_provider_hint' => 'Различия бэкендов:',
         ],
+        'create_operations' => 'Операции создания узла',
+        'update_operations' => 'Операции обновления узла',
+        'delete_operations' => 'Операции удаления узла',
+        'operation' => [
+            'auth_failed' => 'Не удалось создать авторизацию узла',
+            'create_auth' => 'Создать авторизацию узла',
+            'delete_node' => 'Удалить узел',
+            'handle_ddns' => 'Обработать записи DDNS',
+            'reload_node' => 'Перезагрузить узел',
+            'save_node_info' => 'Сохранить информацию об узле',
+            'store_domain_record' => 'Сохранить запись DDNS',
+            'sync_labels' => 'Синхронизировать метки',
+            'delete_domain_record' => 'Удалить запись DDNS',
+            'unchanged' => 'Без изменений',
+            'refresh_geo' => 'Обновить геолокационную информацию',
+        ],
         'proxy_info' => '*Совместимость протокола SS',
         'proxy_info_hint' => 'Режим совместимости требует добавления <span class="red-700">_compatible</span> к имени бэкенда',
         'refresh_geo' => 'Обновить геолокацию',

+ 1 - 1
resources/lang/ru/model.php

@@ -94,8 +94,8 @@ return [
         'relay_port' => 'Порт ретрансляции',
         'renewal_cost' => 'Стоимость продления',
         'service_port' => 'Порт сервиса',
+        'service_password' => 'Пароль сервиса',
         'single' => 'Режим одного порта',
-        'single_passwd' => 'Пароль одного порта',
         'static' => 'Статус онлайн',
         'subscription_term' => 'Срок подписки',
         'traffic_limit' => 'Лимит трафика',

+ 16 - 0
resources/lang/vi/admin.php

@@ -329,6 +329,22 @@ return [
             'v2_net_hint' => '⚠️ Truyền tải WebSocket cần bật TLS',
             'v2_tls_provider_hint' => 'Giải thích khác biệt backend:',
         ],
+        'create_operations' => 'Thao tác tạo nút',
+        'update_operations' => 'Thao tác cập nhật nút',
+        'delete_operations' => 'Thao tác xóa nút',
+        'operation' => [
+            'auth_failed' => 'Tạo ủy quyền nút thất bại',
+            'create_auth' => 'Tạo ủy quyền nút',
+            'delete_node' => 'Xóa nút',
+            'handle_ddns' => 'Xử lý bản ghi DDNS',
+            'reload_node' => 'Tải lại nút',
+            'save_node_info' => 'Lưu thông tin nút',
+            'store_domain_record' => 'Lưu bản ghi DDNS',
+            'sync_labels' => 'Đồng bộ nhãn',
+            'delete_domain_record' => 'Xóa bản ghi DDNS',
+            'unchanged' => 'Không thay đổi',
+            'refresh_geo' => 'Cập nhật thông tin vị trí địa lý',
+        ],
         'proxy_info' => '*Giải thích tương thích giao thức SS',
         'proxy_info_hint' => 'Chế độ tương thích cần thêm <span class="red-700">_compatible</span> vào tên cài đặt backend',
         'refresh_geo' => 'Cập nhật thông tin vị trí địa lý',

+ 1 - 1
resources/lang/vi/model.php

@@ -94,8 +94,8 @@ return [
         'relay_port' => 'Port chuyển tiếp',
         'renewal_cost' => 'Chi phí gia hạn',
         'service_port' => 'Port dịch vụ',
+        'service_password' => 'Mật khẩu dịch vụ',
         'single' => 'Port đơn',
-        'single_passwd' => 'Mật khẩu port đơn',
         'static' => 'Trạng thái hoạt động',
         'subscription_term' => 'Thời hạn đăng ký',
         'traffic_limit' => 'Giới hạn lưu lượng',

+ 16 - 0
resources/lang/zh_CN/admin.php

@@ -329,6 +329,22 @@ return [
             'v2_net_hint' => '⚠️ WebSocket传输需启用TLS',
             'v2_tls_provider_hint' => '后端差异说明:',
         ],
+        'create_operations' => '节点创建操作',
+        'update_operations' => '节点更新操作',
+        'delete_operations' => '节点删除操作',
+        'operation' => [
+            'auth_failed' => '创建节点授权失败',
+            'create_auth' => '创建节点授权',
+            'delete_node' => '删除节点',
+            'handle_ddns' => '处理DDNS记录',
+            'reload_node' => '重载节点',
+            'save_node_info' => '保存节点信息',
+            'store_domain_record' => '存储DDNS记录',
+            'sync_labels' => '同步标签',
+            'delete_domain_record' => '删除DDNS记录',
+            'unchanged' => '未发生变化',
+            'refresh_geo' => '更新地理位置信息',
+        ],
         'proxy_info' => '*SS协议兼容说明',
         'proxy_info_hint' => '兼容模式需在后端配置名添加<span class="red-700">_compatible</span>',
         'refresh_geo' => '刷新地理位置',

+ 1 - 1
resources/lang/zh_CN/model.php

@@ -94,8 +94,8 @@ return [
         'relay_port' => '中转端口',
         'renewal_cost' => '续费金额',
         'service_port' => '服务端口',
+        'service_password' => '服务密码',
         'single' => '单端口',
-        'single_passwd' => '单端口密码',
         'static' => '运行状态',
         'subscription_term' => '订阅周期',
         'traffic_limit' => '流量限制',

+ 352 - 296
resources/views/admin/node/index.blade.php

@@ -1,4 +1,38 @@
 @extends('admin.table_layouts')
+@push('css')
+    <style>
+        .modal-body {
+            max-height: 60vh;
+            overflow-y: auto;
+        }
+
+        .list-icons>li {
+            border-bottom: 1px solid #e4eaec !important;
+            padding: 5px 8px;
+        }
+
+        .list-icons>li:last-of-type {
+            border-bottom: none !important;
+        }
+
+        .sub-container {
+            border-left: 2px solid #e9ecef;
+        }
+
+        .sub-container>li {
+            padding: 8px 10px;
+            border-bottom: 1px dashed #e9ecef !important;
+            font-size: 0.9em;
+        }
+
+        .operation-message {
+            max-width: 60%;
+            word-wrap: break-word;
+            word-break: break-all;
+            white-space: normal;
+        }
+    </style>
+@endpush
 @section('content')
     <div class="page-content container-fluid">
         <x-admin.table-panel :title="trans('admin.menu.node.list')" :theads="[
@@ -21,18 +55,18 @@
                         @can('admin.node.reload')
                             @if ($nodeList->where('type', 4)->count())
                                 <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') }}
+                                    <i class="icon wb-reload" id="reload_all" 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="handleNodeAction('geo')">
-                                <i class="icon wb-map" id="geo_0" aria-hidden="true"></i> {{ trans('admin.node.refresh_geo_all') }}
+                                <i class="icon wb-map" id="geo_all" 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') }}
+                                <i class="icon wb-signal" id="check_all" aria-hidden="true"></i> {{ trans('admin.node.connection_test_all') }}
                             </button>
                         @endcan
                         @can('admin.node.create')
@@ -103,7 +137,7 @@
                                         <x-ui.dropdown-item :url="route('admin.node.clone', $node)" icon="wb-copy" :text="trans('admin.clone')" />
                                     @endcan
                                     @can('admin.node.destroy')
-                                        <x-ui.dropdown-item color="red-700" url="javascript:(0)" attribute="data-action=delete" icon="wb-trash" :text="trans('common.delete')" />
+                                        <x-ui.dropdown-item color="red-700" url="javascript:destroy('{{ $node->id }}')" icon="wb-trash" :text="trans('common.delete')" />
                                     @endcan
                                     @can('admin.node.monitor')
                                         <x-ui.dropdown-item :url="route('admin.node.monitor', $node)" icon="wb-stats-bars" :text="trans('admin.node.traffic_monitor')" />
@@ -164,7 +198,7 @@
                                             <x-ui.dropdown-item :url="route('admin.node.clone', $childNode)" icon="wb-copy" :text="trans('admin.clone')" />
                                         @endcan
                                         @can('admin.node.destroy')
-                                            <x-ui.dropdown-item color="red-700" url="javascript:(0)" attribute="data-action=delete" icon="wb-trash" :text="trans('common.delete')" />
+                                            <x-ui.dropdown-item color="red-700" url="javascript:destroy('{{ $childNode->id }}')" icon="wb-trash" :text="trans('common.delete')" />
                                         @endcan
                                         @can('admin.node.monitor')
                                             <x-ui.dropdown-item :url="route('admin.node.monitor', $childNode)" icon="wb-stats-bars" :text="trans('admin.node.traffic_monitor')" />
@@ -199,238 +233,243 @@
     <!-- 节点重载结果模态框 -->
     <x-ui.modal id="nodeReloadModal" :title="trans('admin.node.reload')" size="lg">
     </x-ui.modal>
+
+    <!-- 节点删除结果模态框 -->
+    <x-ui.modal id="nodeDeleteModal" :title="trans('admin.node.delete_operations')" 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') }}'
+            "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/'' 表示批量)
-            results: {}, // 按 nodeId 存储节点信息与已收到的数据
-            finished: {}, // 标记 nodeId 是否完成
-            spinnerFallbacks: {} // 防止无限 spinner 的后备定时器
+
+        // 操作上下文管理 - 记录当前正在进行中的操作
+        const actionContexts = {
+            check: null,
+            geo: null,
+            reload: null,
+            delete: null
         };
 
+        // 网络状态映射
         const networkStatus = @json(trans('admin.network_status'));
 
-        // 配置表:保留原按钮 id 规则 & 原模态结构
+        // 操作名称映射
+        const operationNames = {
+            "handle_ddns": '{{ trans('admin.node.operation.handle_ddns') }}',
+            "delete_node": '{{ trans('admin.node.operation.delete_node') }}'
+        };
+
+        // 子操作名称映射
+        const subOperationNames = {
+            "destroy": '{{ trans('admin.node.operation.delete_domain_record') }}'
+        };
+
+        // 操作配置表
         const ACTION_CFG = {
             check: {
-                icon: 'wb-signal',
+                icon: "wb-signal",
                 routeTpl: '{{ route('admin.node.check', 'PLACEHOLDER') }}',
-                event: '.node.actions',
-                modal: '#nodeCheckModal',
-                btnSelector: (id) => id ? $(`#node_${id}`) : $('#check_all_nodes'),
+                modal: "#nodeCheckModal",
+                btnSelector: (id) => id ? $(`#node_${id}`) : $("#check_all"),
                 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',
+                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);
-                },
+                modal: "#nodeGeoRefreshModal",
+                btnSelector: (id) => id ? $(`#geo_${id}`) : $("#geo_all"),
+                buildUI: buildNodeTableUI,
+                updateUI: updateNodeOperationUI,
                 successMsg: '{{ trans('common.completed_item', ['attribute' => trans('admin.node.refresh_geo')]) }}'
             },
             reload: {
-                icon: 'wb-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));
-                },
+                modal: "#nodeReloadModal",
+                btnSelector: (id) => id ? $(`#reload_${id}`) : $("#reload_all"),
+                buildUI: buildNodeTableUI,
+                updateUI: updateNodeOperationUI,
                 successMsg: '{{ trans('common.completed_item', ['attribute' => trans('admin.node.reload')]) }}'
+            },
+            delete: {
+                icon: "wb-trash",
+                routeTpl: '{{ route('admin.node.destroy', 'PLACEHOLDER') }}',
+                modal: "#nodeDeleteModal",
+                btnSelector: () => {},
+                buildUI: buildDeleteUI,
+                updateUI: updateDeleteUI,
+                successMsg: '{{ trans('common.completed_item', ['attribute' => trans('admin.node.delete_operations')]) }}'
             }
         };
 
-        // 统一设置 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 分钟兜底
+        // 统一设置 spinner
+        function setSpinner($el, iconClass, on = false) {
+            if (!$el?.length) return;
+            $el.removeClass(`${iconClass} wb-loop icon-spin`);
+            $el.addClass(on ? "wb-loop icon-spin" : iconClass);
         }
 
-        function clearSpinnerFallback(key) {
-            if (state.spinnerFallbacks[key]) {
-                clearTimeout(state.spinnerFallbacks[key]);
-                delete state.spinnerFallbacks[key];
-            }
+        // 清理函数
+        function cleanupActionContext(type) {
+            const context = actionContexts[type];
+            if (!context) return;
+            window.broadcastingManager.unsubscribe(context.channel);
+            actionContexts[type] = null;
         }
 
         // 通用操作入口
         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;
+            const channel = window.broadcastingManager.getChannelName(`node.${type}`, id);
 
-            // 如果相同操作正在进行并且已有结果缓存,则仅打开 modal(不重复发起
-            if (state.actionType === type && String(state.actionId) === String(id) && Object.keys(state.results).length > 0) {
-                $(cfg.modal).modal('show');
+            // 如果已有操作在进行中,直接显示 modal(不重新发起请求
+            if (actionContexts[type]) {
+                $(cfg.modal).modal("show");
                 return;
             }
 
-            // 开始新操作:清理之前的连接/缓存
-            state.actionType = type;
-            state.actionId = id;
-            state.results = {};
-            state.finished = {};
+            // 记录当前操作上下文
+            actionContexts[type] = {
+                actionId: id,
+                channel: channel,
+                $btn: $btn
+            };
 
-            // 启动 spinner(保持加载直到我们检测到完成)
             setSpinner($btn, cfg.icon, true);
-            // 启动后备定时器
-            const fallbackKey = `${type}_${id ?? 'all'}`;
-            startSpinnerFallback(fallbackKey, $btn, cfg.icon);
 
-            // 使用统一的广播管理器订阅频道
-            const success = window.broadcastingManager.subscribe(
-                channelName,
-                cfg.event,
-                (e) => handleResult(e.data || e, type, id, $btn)
-            );
+            // 订阅广播频道
+            const success = window.broadcastingManager.subscribe(channel, ".node.actions", (e) => handleResult(type, id, e.data || e));
 
             if (!success) {
-                // 订阅失败:恢复按钮状态
-                setSpinner($btn, cfg.icon, false);
-                clearSpinnerFallback(fallbackKey);
+                setSpinner($btn, cfg.icon);
+                actionContexts[type] = null;
                 return;
             }
 
-            // 触发后端接口(Ajax)
-            ajaxPost(jsRoute(routeTpl, id), {}, {
-                beforeSend: function() {
-                    // spinner 已经设置
-                },
-                success: function(ret) {
-                    // 不在此处处理最终结果,交由广播处理(避免 race)
-                },
-                error: function(xhr, status, error) {
-                    if (!window.broadcastingManager.isConnected()) {
-                        window.broadcastingManager.handleError(i18n('broadcast.websocket_unavailable'));
-                    } 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);
+            const routeUrl = jsRoute(cfg.routeTpl, id);
+
+            // AJAX 调用
+            const ajaxOptions = {
+                success: () => {},
+                error: (xhr, status, error) => {
+                    window.broadcastingManager.handleAjaxError(
+                        '{{ trans('common.error') }}',
+                        `{{ trans('common.request_failed') }} ${error}: ${xhr?.responseJSON?.exception}`
+                    );
+                    setSpinner($btn, cfg.icon);
+                    cleanupActionContext(type);
                 }
-            });
+            };
+
+            if (type === "delete") {
+                ajaxDelete(routeUrl, {}, ajaxOptions);
+            } else {
+                ajaxPost(routeUrl, {}, ajaxOptions);
+            }
         }
 
-        // 处理广播数据的统一入口
-        function handleResult(e, type, id, $btn) {
+        // 处理广播数据
+        function handleResult(type, id, e) {
             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;
-            }
+            const context = actionContexts[type];
 
-            // 处理详细数据
-            try {
-                const nodeId = e.nodeId;
-                if (!nodeId || !state.results[nodeId]) return;
+            if (!cfg || !context) return;
 
-                if (type === 'check' && (e.icmp !== undefined || e.tcp !== undefined)) {
-                    if (!state.results[nodeId].data[e.ip]) {
-                        state.results[nodeId].data[e.ip] = {};
+            if (e.list) {
+                cfg.buildUI(e, type);
+            } else {
+                cfg.updateUI(e.node_id || context.actionId, e, type);
+
+                // 检查是否所有操作都完成
+                const modal = $(cfg.modal);
+                if (modal.find(".icon-spin").length === 0) {
+                    setSpinner(context.$btn, cfg.icon);
+
+                    if (cfg.successMsg) {
+                        toastr.success(cfg.successMsg);
                     }
-                    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);
                 }
+            }
+        }
+
+        function getStatusIcon(status) {
+            return status === 1 ? `<i class="icon wb-check text-success"></i>` : `<i class="icon wb-close text-danger"></i>`;
+        }
+
+        // 通用UI构建函数
+        function buildNodeTableUI(e, type) {
+            const modalSelector = ACTION_CFG[type]?.modal;
+            $(modalSelector).modal("show");
+
+            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.entries(e.list).forEach(([nodeId, nodeName]) => {
+                html += `<tr data-node-id="${nodeId}">
+                            <td>${nodeName}</td>
+                            <td><i class="wb-loop icon-spin"></i></td>
+                            <td></td>
+                        </tr>`;
+            });
 
-                // 检查是否所有节点都完成
-                const allDone = Object.keys(state.results).length > 0 &&
-                    Object.keys(state.results).every(nodeId => cfg.isNodeDone(state.results[nodeId]));
+            document.querySelector(`${modalSelector} .modal-body`).innerHTML = html + "</tbody></table>";
+        }
 
-                if (allDone) {
-                    const fallbackKey = `${type}_${id ?? 'all'}`;
-                    setSpinner($btn, cfg.icon, false);
-                    clearSpinnerFallback(fallbackKey);
-                    toastr.success(cfg.successMsg);
+        // 通用节点操作UI更新函数
+        function updateNodeOperationUI(nodeId, data, type) {
+            const modalSelector = ACTION_CFG[type]?.modal;
+            const row = document.querySelector(`${modalSelector} tr[data-node-id="${nodeId}"]`);
+            if (!row) return;
+
+            // 默认处理方式(适用于reload等简单操作)
+            let info = data.message || "";
+
+            // 特殊处理geo操作
+            if (type === "geo" && data.status === 1 && data.original && data.update) {
+                info = JSON.stringify(data.original) !== JSON.stringify(data.update) ?
+                    `{{ trans('common.update') }}: [${data.original.join(", ")}] => [${data.update.join(", ")}]` : '{{ trans('Not Modified') }}';
+            } else if (type === "reload") {
+                if (info.message) {
+                    info = info.message;
+                } else {
+                    info = '{{ trans('common.success_item', ['attribute' => trans('admin.node.operation.reload_node')]) }}: ' + data?.success.join(', ');
+                    if (data.error && data.error.length > 0) {
+                        info += ' | {{ trans('common.failed') }}: ' + data.error.join(', ');
+                    }
                 }
-            } catch (err) {
-                console.error('handleResult error', err);
             }
+
+            row.querySelector("td:nth-child(2)").innerHTML = getStatusIcon(data.status);
+            row.querySelector("td:nth-child(3)").innerHTML = info;
         }
 
         // 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];
+        function buildCheckUI(e) {
+            $("#nodeCheckModal").modal("show");
+            let html = `<div class="row">`;
+            const columnClass = Object.keys(e.list).length > 1 ? "col-md-6" : "col-12";
+
+            Object.entries(e.list).forEach(([nodeId, node]) => {
                 html += `
                     <div class="${columnClass}" data-node-id="${nodeId}">
                         <h5>${node.name}</h5>
@@ -443,157 +482,174 @@
                                 </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>`;
-                    });
-                }
+
+                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;
+            document.querySelector("#nodeCheckModal .modal-body").innerHTML = html + "</div>";
         }
 
         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) {}
+            const row = document.querySelector(`#nodeCheckModal div[data-node-id="${nodeId}"] tr[data-ip="${data.ip}"]`);
+            if (!row) return;
+
+            row.querySelector("td:nth-child(2)").innerHTML = networkStatus[data.icmp] || networkStatus[4];
+            row.querySelector("td:nth-child(3)").innerHTML = networkStatus[data.tcp] || networkStatus[4];
         }
 
-        // 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>`;
+        // delete UI
+        function buildDeleteUI(e) {
+            $("#nodeDeleteModal").modal("show");
+            let html = '<ul class="list-icons">';
 
-            Object.keys(state.results).forEach(nodeId => {
-                const node = state.results[nodeId];
+            // e.list 是数组形式: ['delete_node', 'handle_ddns']
+            e.list.forEach(operation => {
+                const operationName = operationNames[operation] || operation;
                 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>`;
+                    <li class="d-flex justify-content-between align-items-center" data-operation="${operation}">
+                        <i class="wb-loop icon-spin"></i>
+                        <div class="flex-grow-1">
+                            ${operationName}
+                        </div>
+                        <div class="operation-message text-muted small"></div>
+                    </li>
+                    <ul class="sub-container list-icons"></ul>`;
             });
-            html += '</tbody></table></div>';
-            body.innerHTML = html;
+
+            document.querySelector("#nodeDeleteModal .modal-body").innerHTML = html + '</ul>';
         }
 
-        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 || '-';
-
-                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(', ') || '-'}]`;
-                }
+        function updateDeleteUI(nodeId, data) {
+            if (!data.operation) return;
 
-                statusEl.innerHTML = status;
-                infoEl.innerHTML = info;
-            } catch (e) {}
-        }
+            const $operationItem = $(`#nodeDeleteModal [data-operation="${data.operation}"]`);
+            if (!$operationItem.length) return;
 
-        // 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>`;
+            if (!data.sub_operation || data.sub_operation === 'list') {
+                $operationItem.find('i:first').replaceWith(getStatusIcon(data.status));
+            }
 
-            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;
+            // 处理子操作(如 DDNS 操作)
+            if (data.sub_operation) {
+                handleDeleteSubOperation($operationItem, data);
+            } else if (data.message) {
+                $operationItem.find(".operation-message").text(data.message);
+            }
+
+            // 所有操作完成后显示按钮
+            showDeleteCompletionButton();
         }
 
-        function updateReloadUI(nodeId, data) {
-            try {
-                const row = document.querySelector(`#nodeReloadModal tr[data-node-id="${nodeId}"]`);
-                if (!row) return;
+        // 处理删除操作的子操作
+        function handleDeleteSubOperation($operationItem, data) {
+            // 查找或创建子操作容器
+            let $container = $operationItem.nextAll(`.sub-container`).first();
+
+            if ($container.length === 0) return;
+
+            // 特殊处理 DDNS 操作中的 IP 列表预显示
+            if (data.delete) {
+                data.delete.forEach(ip => {
+                    createSubOperationItem($container, 'destroy', ip);
+                });
+            } else {
+                const subOpKey = `${data.sub_operation}_${data.data || ''}`;
+                // 更新或创建子操作项
+                let $item = $container.find(`[data-sub-operation="${subOpKey}"]`);
+                $item.find('i:first').replaceWith(getStatusIcon(data.status));
+                if (data.message) {
+                    $item.find('.operation-message').text(data.message);
+                }
+            }
+        }
 
-                const statusEl = row.querySelector('td:nth-child(2)');
-                const infoEl = row.querySelector('td:nth-child(3)');
+        // 创建删除操作的子操作项
+        function createSubOperationItem($container, operation, data) {
+            let key = operation + '_' + data;
+            let $item = $container.find(`[data-sub-operation="${key}"]`);
+            const opName = subOperationNames[operation] || operation;
+            const displayText = data ? `${opName} (${data})` : opName;
 
-                if (!statusEl || !infoEl) return;
+            if ($item.length) return;
 
-                // 处理状态显示
-                let status = '❌'; // 默认失败状态
-                let info = '';
+            $item = $(`
+                <li class="d-flex justify-content-between align-items-center" data-sub-operation="${key}">
+                    <i class="wb-loop icon-spin"></i>
+                    <div class="flex-grow-1">
+                        ${displayText}
+                    </div>
+                    <div class="operation-message text-muted small"></div>
+                </li>
+            `);
+            $container.append($item);
+        }
 
-                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(', ')}`;
-                }
+        // 显示删除完成确认按钮
+        function showDeleteCompletionButton() {
+            const $modal = $("#nodeDeleteModal");
+            if ($modal.find(".icon-spin").length !== 0 || $modal.find(".modal-footer").length > 0) return;
 
-                statusEl.innerHTML = status;
-                infoEl.innerHTML = info;
-            } catch (e) {}
+            $modal.find(".modal-content").append(`
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-primary" data-dismiss="modal">{{ trans('common.confirm') }}</button>
+                </div>`);
         }
 
         @can('admin.node.reload')
             function reload(id = null) {
+                if (actionContexts['reload']) {
+                    $(ACTION_CFG['reload'].modal).modal("show");
+                } else {
+                    showConfirm({
+                        title: '{{ trans('admin.node.reload_confirm') }}',
+                        onConfirm: () => handleNodeAction("reload", id)
+                    });
+                }
+            }
+        @endcan
+
+        @can('admin.node.destroy')
+            function destroy(id = null) {
+                const nodeName = $(`tr:has(td:first-child:contains('${id}')) td:nth-child(3)`).text().trim() || id || "";
+
                 showConfirm({
-                    text: '{{ trans('admin.node.reload_confirm') }}',
-                    onConfirm: function() {
-                        handleNodeAction('reload', id);
-                    }
+                    title: '{{ trans('common.warning') }}',
+                    text: i18n("confirm.delete")
+                        .replace("{attribute}", '{{ trans('model.node.attribute') }}')
+                        .replace("{name}", nodeName),
+                    icon: "warning",
+                    onConfirm: () => handleNodeAction("delete", id)
                 });
             }
         @endcan
+
+        // 检测、地理位置、重载 modal 的通用处理
+        Object.keys(ACTION_CFG).forEach(type => {
+            const modalSelector = ACTION_CFG[type].modal;
+            $(document).on("hidden.bs.modal", modalSelector, function() {
+                const context = actionContexts[type];
+                const modalBody = document.querySelector(`${modalSelector} .modal-body`);
+                const isLoading = modalBody && modalBody.querySelectorAll('.icon-spin').length > 0;
+
+                if (!isLoading && context) {
+                    cleanupActionContext(type);
+                    // 清空 modal 内容
+                    if (modalBody) {
+                        modalBody.innerHTML = '';
+                    }
+                    if (type === 'delete') {
+                        location.reload();
+                    }
+                }
+            });
+        });
     </script>
 @endpush

+ 247 - 18
resources/views/admin/node/info.blade.php

@@ -11,6 +11,32 @@
         .bootstrap-select .dropdown-menu {
             max-height: 50vh !important;
         }
+
+        .list-icons>li {
+            border-bottom: 1px solid #e4eaec !important;
+            padding: 5px 8px;
+        }
+
+        .list-icons>li:last-of-type {
+            border-bottom: none !important;
+        }
+
+        .sub-container {
+            border-left: 2px solid #e9ecef;
+        }
+
+        .sub-container>li {
+            padding: 8px 10px;
+            border-bottom: 1px dashed #e9ecef !important;
+            font-size: 0.9em;
+        }
+
+        .operation-message {
+            max-width: 60%;
+            word-wrap: break-word;
+            word-break: break-all;
+            white-space: normal;
+        }
     </style>
 @endsection
 @section('content')
@@ -84,7 +110,13 @@
                         </div>
                         <!-- 代理 设置部分 -->
                         <div class="proxy-config">
-                            <x-admin.form.radio-group name="type" :label="trans('model.common.type')" :options="[0 => 'Shadowsocks', 1 => 'ShadowsocksR', 2 => 'V2Ray', 3 => 'Trojan', 4 => 'VNET']" />
+                            <x-admin.form.radio-group name="type" :label="trans('model.common.type')" :options="[
+                                0 => 'Shadowsocks',
+                                1 => 'ShadowsocksR',
+                                2 => 'V2Ray',
+                                3 => 'Trojan',
+                                4 => 'VNET',
+                            ]" />
                             <hr />
                             <!-- SS/SSR 设置部分 -->
                             <div class="ss-setting">
@@ -93,6 +125,14 @@
                                 {{--                                <x-admin.form.select name="plugin" :label="trans('model.node.plugin')" :options="['none'=>'None', 'kcptun'=>'Kcptun', 'v2ray-plugin' => 'V2ray-plugin', 'cloak'=> 'Cloak', 'shadow-tls' => 'Shadow-tls']" /> --}}
                                 {{--                                <x-admin.form.textarea name="plugin_opts" :label="trans('model.node.plugin_opts')" /> --}}
 
+                                <x-admin.form.input name="passwd" :label="trans('model.node.service_password')" />
+                                <x-admin.form.input name="single" type="checkbox" :label="trans('model.node.single')"
+                                                    attribute="data-plugin=switchery onchange=switchSetting('single')" />
+                                <div class="single-setting">
+                                    <x-admin.form.input name="port" type="number" :label="trans('model.node.service_port')" :help="trans('admin.node.info.single_hint')" />
+                                </div>
+
+                                <hr />
                                 <div class="ssr-setting">
                                     <x-admin.form.select name="protocol" :label="trans('model.node.protocol')" :options="$protocols" />
                                     <x-admin.form.textarea name="protocol_param" :label="trans('model.node.protocol_param')" />
@@ -103,15 +143,7 @@
                                             {!! trans('admin.node.proxy_info_hint') !!}
                                         </div>
                                     </x-admin.form.skeleton>
-                                </div>
-
-                                <hr />
-                                <x-admin.form.input name="single" type="checkbox" :label="trans('model.node.single')"
-                                                    attribute="data-plugin=switchery onchange=switchSetting('single')" :help="trans('admin.node.info.single_hint')" />
-
-                                <div class="single-setting">
-                                    <x-admin.form.input name="port" type="number" :label="trans('model.node.service_port')" :help="trans('admin.node.info.single_hint')" />
-                                    <x-admin.form.input name="passwd" :label="trans('model.node.single_passwd')" />
+                                    <hr />
                                 </div>
                             </div>
 
@@ -131,7 +163,7 @@
                                     'kcp' => 'mKCP',
                                     'ws' => 'WebSocket',
                                     'httpupgrade' => 'HTTPUpgrade',
-                                    'xhttp' => 'xHTTP   ',
+                                    'xhttp' => 'xHTTP',
                                     'h2' => 'HTTP/2',
                                     'quic' => 'QUIC',
                                     'domainsocket' => 'DomainSocket',
@@ -174,6 +206,10 @@
             </x-admin.form.container>
         </x-ui.panel>
     </div>
+
+    <!-- 节点结果模态框 -->
+    <x-ui.modal id="nodeModal" :title="isset($node) ? trans('admin.node.create_operations') : trans('admin.node.update_operations')" size="lg">
+    </x-ui.modal>
 @endsection
 @section('javascript')
     <script src="/assets/global/vendor/bootstrap-select/bootstrap-select.min.js"></script>
@@ -186,9 +222,37 @@
     <script src="/assets/global/js/Plugin/bootstrap-datepicker.js"></script>
     <script src="/assets/global/vendor/switchery/switchery.min.js"></script>
     <script src="/assets/global/js/Plugin/switchery.js"></script>
+    @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 string = "{{ strtolower(Str::random()) }}";
 
+        // 使用 broadcastingManager 管理广播连接
+        const operationNames = {
+            'save_node_info': '{{ trans('admin.node.operation.save_node_info') }}',
+            'create_auth': '{{ trans('admin.node.operation.create_auth') }}',
+            'sync_labels': '{{ trans('admin.node.operation.sync_labels') }}',
+            'handle_ddns': '{{ trans('admin.node.operation.handle_ddns') }}',
+            'reload_node': '{{ trans('admin.node.operation.reload_node') }}',
+            'refresh_geo': '{{ trans('admin.node.operation.refresh_geo') }}'
+        };
+
+        // 子操作名称映射
+        const subOperationNames = {
+            'store': '{{ trans('admin.node.operation.store_domain_record') }}',
+            'destroy': '{{ trans('admin.node.operation.delete_domain_record') }}',
+            'unchanged': '{{ trans('admin.node.operation.unchanged') }}',
+        };
+
         function calculateNextNextRenewalDate() {
             const nextRenewalDate = $("#next_renewal_date").val();
             const termValue = parseInt($("#subscription_term_value").val() || 0);
@@ -261,6 +325,7 @@
                 relay_node_id: '',
                 type: 1
             };
+
             @isset($node)
                 // 反向解析节点数据以适配表单字段
                 const node = @json($node);
@@ -276,6 +341,9 @@
                     nodeData.subscription_term_value = value;
                     nodeData.subscription_term_unit = unit;
                 }
+                setupBroadcastChannel('update', node.id);
+            @else
+                setupBroadcastChannel('create');
             @endisset
 
             // 自动填充表单
@@ -293,9 +361,143 @@
             $("#obfs").on("changed.bs.select", toggleObfsParam);
             $("#relay_node_id").on("changed.bs.select", toggleRelayConfig);
             $("#v2_net").on("changed.bs.select", updateV2RaySettings);
-            $(document).on("change", "#next_renewal_date, #subscription_term_value, #subscription_term_unit", calculateNextNextRenewalDate);
+            $(document).on("change", "#next_renewal_date, #subscription_term_value, #subscription_term_unit",
+                calculateNextNextRenewalDate);
+        }
+
+        // 建立广播频道连接
+        function setupBroadcastChannel(actionType, nodeId = null) {
+            // 使用 broadcastingManager 订阅频道
+            window.broadcastingManager.subscribe(
+                window.broadcastingManager.getChannelName(`node.${actionType}`, nodeId),
+                '.node.actions',
+                (e) => handleEditProgress(e.data || e)
+            );
+        }
+
+        // 处理编辑进度更新
+        function handleEditProgress(data) {
+            if (data.list) {
+                showOperationList(data.list);
+            } else if (data.operation) {
+                updateOperationStatus(data);
+            }
         }
 
+        // 显示操作清单
+        function showOperationList(operationList) {
+            $('#nodeModal').modal('show');
+            let html = '<ul class="list-icons">';
+
+            operationList.forEach(operation => {
+                const opName = operationNames[operation] || operation;
+
+                html += `
+                    <li class="d-flex justify-content-between align-items-center" data-operation="${operation}">
+                        <i class="wb-loop icon-spin"></i>
+                        <div class="flex-grow-1">
+                            ${opName}
+                        </div>
+                        <div class="operation-message text-muted small"></div>
+                    </li>
+                    <ul class="sub-container list-icons"></ul>
+                `;
+            });
+
+            $('#nodeModal .modal-body').html(html + '</ul>');
+        }
+
+        function getStatusIcon(status) {
+            return status === 1 ? `<i class="icon wb-check text-success"></i>` :
+                `<i class="icon wb-close text-danger"></i>`;
+        }
+
+        // 更新操作状态
+        function updateOperationStatus(data) {
+            const $operationItem = $(`#nodeModal [data-operation="${data.operation}"]`);
+            if (!$operationItem.length) return;
+            if (!data.sub_operation || data.sub_operation === 'list' || data.sub_operation === 'unchanged') {
+                $operationItem.find('i:first').replaceWith(getStatusIcon(data.status));
+            }
+
+            // 处理子操作(如 DDNS 操作、IP列表等)
+            if (data.sub_operation) {
+                handleSubOperation($operationItem, data);
+            } else if (data.message) {
+                $operationItem.find(".operation-message").text(data.message);
+            }
+
+            // 检查是否所有操作都已完成
+            showCompletionButton();
+        }
+
+        function handleSubOperation($operationItem, data) {
+            // 查找或创建子操作容器
+            let $container = $operationItem.nextAll(`.sub-container`).first();
+
+            if ($container.length === 0) return;
+
+            // 特殊处理 DDNS 操作中的 IP 列表预显示
+            if (data.add || data.delete) {
+                data.add?.forEach(ip => {
+                    createSubOperationItem($container, 'store', ip);
+                });
+
+                data.delete?.forEach(ip => {
+                    createSubOperationItem($container, 'destroy', ip);
+                });
+            } else {
+                const subOpKey = `${data.sub_operation}_${data.data || ''}`;
+                // 更新或创建子操作项
+                let $item = $container.find(`[data-sub-operation="${subOpKey}"]`);
+                $item.find('i:first').replaceWith(getStatusIcon(data.status));
+                if (data.message) {
+                    $item.find('.operation-message').text(data.message);
+                }
+            }
+        }
+
+        // 创建或更新子操作项的辅助函数
+        function createSubOperationItem($container, operation, data) {
+            let key = operation + '_' + data;
+            let $item = $container.find(`[data-sub-operation="${key}"]`);
+            const opName = subOperationNames[operation] || operation;
+            const displayText = data ? `${opName} (${data})` : opName;
+
+            if ($item.length) return;
+
+            $item = $(`
+                    <li class="d-flex justify-content-between align-items-center" data-sub-operation="${key}">
+                        <i class="wb-loop icon-spin"></i>
+                        <div class="flex-grow-1">
+                            ${displayText}
+                        </div>
+                        <div class="operation-message text-muted small"></div>
+                    </li>
+                `);
+            $container.append($item);
+        }
+
+        // 显示完成确认按钮
+        function showCompletionButton() {
+            const $modal = $('#nodeModal');
+            if ($modal.find(".icon-spin").length !== 0 || $modal.find(".modal-footer").length > 0) return;
+
+            $modal.find(".modal-content").append(`
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-primary" data-dismiss="modal">{{ trans('common.confirm') }}</button>
+                </div>`);
+        }
+
+        // 同时绑定模态框关闭事件
+        $(document).on("hidden.bs.modal", '#nodeModal', function() {
+            @isset($node)
+                location.reload();
+            @else
+                window.location.href = '{{ route('admin.node.index') }}';
+            @endisset
+        });
+
         function switchSetting(id) {
             const check = document.getElementById(id).checked;
             if (id === "single") {
@@ -381,6 +583,14 @@
 
         // ajax同步提交
         function Submit() {
+            // 防止重复提交
+            const $submitBtn = $('.form-horizontal button[type="submit"]');
+            if ($submitBtn.hasClass('disabled')) {
+                return false;
+            }
+
+            // 禁用提交按钮以防止重复提交
+            $submitBtn.addClass('disabled').prop('disabled', true);
             // 收集表单数据
             const data = collectFormData('.form-horizontal');
 
@@ -395,14 +605,33 @@
                 method: '{{ isset($node) ? 'PUT' : 'POST' }}',
                 data: data,
                 success: function(ret) {
-                    handleResponse(ret, {
-                        redirectUrl: '{{ route('admin.node.index') . (Request::getQueryString() ? '?' . Request::getQueryString() : '') }}'
-                    });
+                    // 成功消息处理在广播中完成
+                    if (ret.status !== 'success') {
+                        // 隐藏可能已经显示的 modal
+                        $('#nodeModal').modal('hide');
+
+                        handleResponse(ret, {
+                            redirectUrl: '{{ route('admin.node.index') . (Request::getQueryString() ? '?' . Request::getQueryString() : '') }}',
+                        });
+                    }
                 },
                 error: function(xhr) {
-                    handleErrors(xhr, {
-                        form: '.form-horizontal'
-                    });
+                    // 隐藏可能已经显示的 modal
+                    $('#nodeModal').modal('hide');
+
+                    // 处理验证错误
+                    if (!window.broadcastingManager.isConnected()) {
+                        // 广播连接错误
+                        window.broadcastingManager.handleError(i18n('broadcast.websocket_unavailable'));
+                    } else {
+                        // 其他错误
+                        handleErrors(xhr, {
+                            form: '.form-horizontal'
+                        });
+                    }
+                },
+                complete: function() {
+                    $submitBtn.removeClass('disabled').prop('disabled', false);
                 }
             });
 

+ 5 - 11
resources/views/admin/user/index.blade.php

@@ -263,7 +263,7 @@
 
             function VNetInfo(id) { // 节点连通性测试
                 const $triggerElement = $(`#vent_${id}`);
-                const channelName = `user.check.${id}`;
+                const channelName = window.broadcastingManager.getChannelName('user.check', id);
 
                 // 清理之前的连接
                 window.broadcastingManager.unsubscribe(channelName);
@@ -294,16 +294,10 @@
                         // 不在此处处理最终结果,交由广播处理(避免 race)
                     },
                     error: function(xhr, status, error) {
-                        if (!window.broadcastingManager.isConnected()) {
-                            window.broadcastingManager.handleError(i18n('broadcast.websocket_unavailable'));
-                        } else {
-                            showMessage({
-                                title: '{{ trans('common.error') }}',
-                                message: `{{ trans('common.request_failed') }} ${error}: ${xhr?.responseJSON?.exception}`,
-                                icon: 'error',
-                                showConfirmButton: true
-                            });
-                        }
+                        window.broadcastingManager.handleAjaxError(
+                            '{{ trans('common.error') }}',
+                            `{{ trans('common.request_failed') }} ${error}: ${xhr?.responseJSON?.exception}`
+                        );
                         // 出错时恢复 spinner
                         $triggerElement.removeClass("wb-loop icon-spin").addClass("wb-link-broken");
                     },

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

@@ -10,7 +10,7 @@
     'focus' => true,
 ])
 
-<div class="modal fade" id="{{ $id }}" role="dialog" aria-hidden="true" aria-labelledby="{{ $labelledby ?? $id }}" tabindex="-1"
+<div class="modal fade" id="{{ $id }}" role="dialog" 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-simple @if ($size) modal-{{ $size }} @endif modal-{{ $position }}">