Browse Source

自定义菜单路由验证

bianjunhui 4 days ago
parent
commit
cf598e276b

+ 148 - 43
packages/Longyi/DynamicMenu/src/Http/Controllers/MenuController.php

@@ -8,18 +8,19 @@ use Longyi\DynamicMenu\Models\MenuItem;
 use Webkul\User\Repositories\RoleRepository;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\View;
+use Illuminate\Support\Facades\Route as RouteFacade;
 
 class MenuController extends Controller
 {
     protected $roleRepository;
-    
+
     public function __construct(RoleRepository $roleRepository)
     {
         $this->roleRepository = $roleRepository;
         $this->_config = request('_config');
     }
-    
-    
+
+
     public function index()
     {
         try {
@@ -28,34 +29,34 @@ class MenuController extends Controller
                 ->orderBy('parent_id', 'ASC')
                 ->orderBy('sort_order', 'ASC')
                 ->get();
-            
+
             // 获取顶级菜单项
-             $menuItems = $allItems->filter(function($item) {
+            $menuItems = $allItems->filter(function($item) {
                 return is_null($item->parent_id) || $item->parent_id == 0;
             })->values();
-            
-            
+
+
             return view('dynamicmenu::admin.menu.index', compact('menuItems', 'allItems'));
-            
+
         } catch (\Exception $e) {
             \Log::error('MenuController 错误:' . $e->getMessage());
             \Log::error($e->getTraceAsString());
-            
+
             session()->flash('error', '加载菜单列表失败:' . $e->getMessage());
             return redirect()->route('admin.dashboard.index');
         }
     }
-    
-    
+
+
     public function create()
     {
         $menuItems = MenuItem::orderBy('parent_id')->orderBy('sort_order')->get();
         return view($this->_config['view'], compact('menuItems'));
     }
-    
+
     public function store(Request $request)
     {
-        
+
         $request->validate([
             'name' => 'required|string|max:255',
             'key' => 'required|string|unique:dynamic_menu_items,key',
@@ -65,32 +66,32 @@ class MenuController extends Controller
             'sort_order' => 'integer',
             'status' => 'boolean'
         ]);
-        
+
         $data = $request->all();
         $data['parent_id'] = $data['parent_id'] === '' ? null : $data['parent_id'];
         $data['created_by'] = auth()->guard('admin')->user()->id;
-        
+
         MenuItem::create($data);
-        
+
         session()->flash('success', '菜单项创建成功');
-        
+
         return redirect()->route($this->_config['redirect']);
     }
-    
+
     public function edit($id)
     {
         try {
             $menuItem = MenuItem::with('parent')->findOrFail($id);
             $menuItems = MenuItem::orderBy('parent_id')->orderBy('sort_order')->get();
-            
+
             return view($this->_config['view'], compact('menuItem', 'menuItems'));
-            
+
         } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
             session()->flash('error', '菜单项不存在');
             return redirect()->route('admin.dynamicmenu.index');
         }
     }
-    
+
     public function update(Request $request, $id)
     {
         try {
@@ -103,14 +104,14 @@ class MenuController extends Controller
                 'sort_order' => 'integer',
                 'status' => 'boolean'
             ]);
-            
+
             $menuItem = MenuItem::findOrFail($id);
             $menuItem->update($request->all());
-            
+
             session()->flash('success', '菜单项更新成功');
-            
+
             return redirect()->route($this->_config['redirect']);
-            
+
         } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
             session()->flash('error', '菜单项不存在');
             return redirect()->back();
@@ -120,24 +121,24 @@ class MenuController extends Controller
             return redirect()->back()->withInput();
         }
     }
-    
-     public function destroy($id)
+
+    public function destroy($id)
     {
         try {
             $menuItem = MenuItem::with('children')->findOrFail($id);
-            
+
             // 检查是否有子菜单
             if ($menuItem->children && $menuItem->children->count() > 0) {
                 session()->flash('error', '请先删除子菜单项');
                 return redirect()->back();
             }
-            
+
             $menuItem->delete();
-            
+
             session()->flash('success', '菜单项删除成功');
-            
+
             return redirect()->route($this->_config['redirect']);
-            
+
         } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
             session()->flash('error', '菜单项不存在');
             return redirect()->back();
@@ -147,43 +148,147 @@ class MenuController extends Controller
             return redirect()->back();
         }
     }
-    
+
     public function permission()
     {
         $roles = $this->roleRepository->all();
         $menuItems = MenuItem::with('children')->orderBy('parent_id')->orderBy('sort_order')->get();
-        
+
         return view($this->_config['view'], compact('roles', 'menuItems'));
     }
-    
+
     public function updatePermission(Request $request)
     {
         $roleId = $request->input('role_id');
         $menuItemIds = $request->input('menu_items', []);
-        
+
         DB::table('menu_item_role')->where('role_id', $roleId)->delete();
-        
+
         foreach ($menuItemIds as $menuItemId) {
             DB::table('menu_item_role')->insert([
                 'menu_item_id' => $menuItemId,
                 'role_id' => $roleId
             ]);
         }
-        
+
         session()->flash('success', '权限更新成功');
-        
+
         return redirect()->back();
     }
-    
+
     public function getRolePermissions(Request $request)
     {
         $roleId = $request->input('role_id');
-        
+
         $permissions = DB::table('menu_item_role')
             ->where('role_id', $roleId)
             ->pluck('menu_item_id')
             ->toArray();
-            
+
         return response()->json($permissions);
     }
-}
+
+    /**
+     * 验证路由是否存在
+     *
+     * @param Request $request
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function validateRoute(Request $request)
+    {
+        $request->validate([
+            'route' => 'required|string'
+        ]);
+
+        $routeName = trim($request->input('route'));
+
+        // 如果是完整URL(以http://或https://开头),跳过验证
+        if (preg_match('/^https?:\/\//i', $routeName)) {
+            return response()->json([
+                'exists' => true,
+                'message' => '外部URL,已跳过验证'
+            ]);
+        }
+
+        // 如果是相对路径(以/开头),跳过验证
+        if (str_starts_with($routeName, '/')) {
+            return response()->json([
+                'exists' => true,
+                'message' => '相对路径,已跳过验证'
+            ]);
+        }
+
+        // 检查路由是否存在
+        try {
+            $route = RouteFacade::getRoutes()->getByName($routeName);
+
+            if ($route) {
+                return response()->json([
+                    'exists' => true,
+                    'message' => "路由 '{$routeName}' 存在",
+                    'methods' => $route->methods(),
+                    'uri' => $route->uri()
+                ]);
+            } else {
+                // 尝试查找相似的路由名称,提供建议
+                $suggestions = $this->findSimilarRoutes($routeName);
+
+                return response()->json([
+                    'exists' => false,
+                    'message' => "路由 '{$routeName}' 不存在",
+                    'suggestions' => $suggestions
+                ], 422);
+            }
+        } catch (\Exception $e) {
+            \Log::error('Route validation error: ' . $e->getMessage());
+
+            return response()->json([
+                'exists' => false,
+                'message' => '路由验证失败:' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 查找相似的路由名称
+     *
+     * @param string $routeName
+     * @return array
+     */
+    protected function findSimilarRoutes(string $routeName): array
+    {
+        $allRoutes = RouteFacade::getRoutes();
+        $similarRoutes = [];
+
+        // 提取路由的主要部分
+        $parts = explode('.', $routeName);
+        $mainPart = $parts[0] ?? '';
+
+        foreach ($allRoutes as $route) {
+            $name = $route->getName();
+
+            if (!$name) {
+                continue;
+            }
+
+            // 计算相似度
+            similar_text($routeName, $name, $percent);
+
+            // 如果相似度超过60%,或者包含相同的前缀
+            if ($percent > 60 || (isset($parts[0]) && str_contains($name, $parts[0]))) {
+                $similarRoutes[] = [
+                    'name' => $name,
+                    'uri' => $route->uri(),
+                    'similarity' => round($percent, 2)
+                ];
+            }
+        }
+
+        // 按相似度排序,取前5个
+        usort($similarRoutes, function($a, $b) {
+            return $b['similarity'] <=> $a['similarity'];
+        });
+
+        return array_slice($similarRoutes, 0, 5);
+    }
+}

+ 227 - 79
packages/Longyi/DynamicMenu/src/Resources/views/admin/menu/create.blade.php

@@ -34,7 +34,7 @@
             </div>
 
             <div class="panel-body" style="padding: 20px;">
-                <form method="POST" action="{{ route('admin.dynamicmenu.store') }}">
+                <form method="POST" action="{{ route('admin.dynamicmenu.store') }}" id="menuForm">
                     @csrf
 
                     {{-- 名称 --}}
@@ -44,6 +44,7 @@
                         </label>
                         <input type="text"
                                name="name"
+                               id="nameInput"
                                class="control"
                                value="{{ old('name') }}"
                                required
@@ -57,16 +58,16 @@
                             Key <span style="color: red;">*</span>
                         </label>
                         <input type="text"
-                            name="key"
-                            class="control"
-                            value="{{ old('key') }}"
-                            required
-                            placeholder="例如:settings.dynamic-menu"
-                            style="width: 100%; padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px;">
+                               name="key"
+                               id="keyInput"
+                               class="control"
+                               value="{{ old('key') }}"
+                               required
+                               placeholder="例如:settings.dynamic-menu"
+                               style="width: 100%; padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px;">
                         <small class="control-info" style="display: block; margin-top: 5px; font-size: 12px; color: #6c757d;">
                             <strong style="color: #dc3545;">重要提示:</strong>
-                            如果想让菜单显示在 Settings 下,Key 必须以 <strong style="background: #f0f0f0; padding: 2px 5px; border-radius: 3px;">settings.</strong> 开头<br>
-                            例如:<code>settings.dynamic-menu</code>、<code>settings.my-menu</code>、<code>settings.custom</code>
+                            如果想让菜单显示在 Settings 下,Key 必须以 <strong>settings.</strong> 开头
                         </small>
                     </div>
 
@@ -75,13 +76,15 @@
                         <label style="display: block; margin-bottom: 5px; font-weight: 500;">URL/路由</label>
                         <input type="text"
                                name="route"
+                               id="routeInput"
                                class="control"
                                value="{{ old('route') }}"
                                placeholder="例如:admin.dashboard"
                                style="width: 100%; padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px;">
                         <small class="control-info" style="display: block; margin-top: 5px; font-size: 12px; color: #6c757d;">
-                            可以是相对路径、完整URL或路由名称
+                            可以是路由名称(如:admin.dashboard)或完整URL
                         </small>
+                        <div id="routeValidationResult" style="margin-top: 5px; font-size: 12px;"></div>
                     </div>
 
                     {{-- 图标 --}}
@@ -93,9 +96,6 @@
                                value="{{ old('icon', 'fas fa-file') }}"
                                placeholder="例如:fas fa-dashboard、fas fa-users等"
                                style="width: 100%; padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px;">
-                        <small class="control-info" style="display: block; margin-top: 5px; font-size: 12px; color: #6c757d;">
-                            FontAwesome图标类名,如:fas fa-home
-                        </small>
                     </div>
 
                     {{-- 父级菜单 --}}
@@ -123,9 +123,6 @@
                                value="{{ old('sort_order', 0) }}"
                                min="0"
                                style="width: 100%; padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px;">
-                        <small class="control-info" style="display: block; margin-top: 5px; font-size: 12px; color: #6c757d;">
-                            数字越小越靠前
-                        </small>
                     </div>
 
                     {{-- 状态 --}}
@@ -157,68 +154,219 @@
         </div>
     </div>
 
-    {{-- 引入必要的JavaScript --}}
-@push('scripts')
-<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
-<script>
-   /* $(document).ready(function() {
-        // 自动生成key
-        $('input[name="name"]').on('blur', function() {
-            var name = $(this).val();
-            var keyInput = $('input[name="key"]');
-
-            if (keyInput.val() === '') {
-                // 将名称转换为小写,空格替换为横线,移除特殊字符
-                var key = name.toLowerCase()
-                             .replace(/\s+/g, '-')
-                             .replace(/[^a-z0-9-]/g, '');
-                // 默认添加到 settings 下
-                keyInput.val('settings.' + key);
-
-                // 添加一个视觉提示
-                keyInput.css('border-color', '#28a745');
-                setTimeout(function() {
-                    keyInput.css('border-color', '');
-                }, 1000);
-
-                // 显示成功提示
-                var successMsg = $('<small class="key-success" style="display: block; margin-top: 5px; font-size: 12px; color: #28a745;">' +
-                                 '✓ 已自动生成Key: settings.' + key + '</small>');
-                keyInput.after(successMsg);
-                setTimeout(function() {
-                    $('.key-success').remove();
-                }, 3000);
-            }
-        });
-
-        // 为所有表单元素添加Bagisto样式类
-        $('.control').addClass('form-control');
-
-        // 检查key格式
-        $('form').on('submit', function(e) {
-            var keyValue = $('input[name="key"]').val();
-            if (keyValue && keyValue.indexOf('settings.') !== 0) {
-                if (!confirm('当前Key不以"settings."开头,菜单可能不会显示在Settings下。确定要继续吗?')) {
-                    e.preventDefault();
+    {{-- JavaScript 代码 - 使用事件委托 --}}
+    @push('scripts')
+        <script>
+            (function() {
+                console.log('DynamicMenu: Script loaded');
+
+                // 使用 setInterval 等待元素出现(因为 Bagisto 可能会延迟渲染)
+                let checkInterval = setInterval(function() {
+                    const routeInput = document.getElementById('routeInput');
+                    if (routeInput) {
+                        clearInterval(checkInterval);
+                        console.log('DynamicMenu: routeInput found!');
+                        initValidation(routeInput);
+                    } else {
+                        console.log('DynamicMenu: waiting for routeInput...');
+                    }
+                }, 100);
+
+                function initValidation(routeInput) {
+                    const validationResult = document.getElementById('routeValidationResult');
+                    const menuForm = document.getElementById('menuForm');
+                    const nameInput = document.getElementById('nameInput');
+                    const keyInput = document.getElementById('keyInput');
+
+                    let validationTimeout = null;
+                    let lastValidatedRoute = '';
+                    let routeIsValid = false;
+
+                    function getCsrfToken() {
+                        const tokenMeta = document.querySelector('meta[name="csrf-token"]');
+                        if (tokenMeta) return tokenMeta.getAttribute('content');
+                        const tokenInput = document.querySelector('input[name="_token"]');
+                        if (tokenInput) return tokenInput.value;
+                        return '';
+                    }
+
+                    function clearValidation() {
+                        if (routeInput) {
+                            routeInput.style.borderColor = '';
+                            routeInput.style.backgroundColor = '';
+                        }
+                        if (validationResult) {
+                            validationResult.innerHTML = '';
+                            validationResult.style.display = 'none';
+                        }
+                        routeIsValid = false;
+                        lastValidatedRoute = '';
+                    }
+
+                    function showLoadingState() {
+                        if (routeInput) {
+                            routeInput.style.borderColor = '#ffc107';
+                            routeInput.style.backgroundColor = '#fffbeb';
+                        }
+                        if (validationResult) {
+                            validationResult.innerHTML = '<span style="color: #ffc107;">⏳ 正在验证路由...</span>';
+                            validationResult.style.display = 'block';
+                        }
+                    }
+
+                    function showSuccessState(message) {
+                        if (routeInput) {
+                            routeInput.style.borderColor = '#28a745';
+                            routeInput.style.backgroundColor = '#f0fff4';
+                        }
+                        if (validationResult) {
+                            validationResult.innerHTML = '<span style="color: #28a745;">✓ ' + message + '</span>';
+                            validationResult.style.display = 'block';
+                        }
+                        setTimeout(() => {
+                            if (routeInput) routeInput.style.backgroundColor = '';
+                        }, 3000);
+                    }
+
+                    function showErrorState(message) {
+                        if (routeInput) {
+                            routeInput.style.borderColor = '#dc3545';
+                            routeInput.style.backgroundColor = '#fff5f5';
+                        }
+                        if (validationResult) {
+                            validationResult.innerHTML = '<span style="color: #dc3545;">✗ ' + message + '</span>';
+                            validationResult.style.display = 'block';
+                        }
+                    }
+
+                    function validateRoute(routeName) {
+                        console.log('Validating route:', routeName);
+
+                        if (!routeName) {
+                            clearValidation();
+                            return;
+                        }
+
+                        showLoadingState();
+
+                        const csrfToken = getCsrfToken();
+                        const validateUrl = '{{ route("admin.dynamicmenu.validate-route") }}';
+
+                        fetch(validateUrl, {
+                            method: 'POST',
+                            headers: {
+                                'Content-Type': 'application/json',
+                                'X-CSRF-TOKEN': csrfToken,
+                                'Accept': 'application/json'
+                            },
+                            body: JSON.stringify({ route: routeName })
+                        })
+                            .then(response => response.json())
+                            .then(data => {
+                                console.log('Validation response:', data);
+                                if (data.exists) {
+                                    routeIsValid = true;
+                                    showSuccessState(data.message);
+                                } else {
+                                    routeIsValid = false;
+                                    let errorMsg = data.message || '路由不存在';
+                                    if (data.suggestions && data.suggestions.length > 0) {
+                                        errorMsg += '<br>建议使用:<br>';
+                                        data.suggestions.forEach(s => {
+                                            errorMsg += `&nbsp;&nbsp;• ${s.name} (${s.uri})<br>`;
+                                        });
+                                    }
+                                    showErrorState(errorMsg);
+                                }
+                                lastValidatedRoute = routeName;
+                            })
+                            .catch(error => {
+                                console.error('Validation error:', error);
+                                routeIsValid = false;
+                                showErrorState('验证失败,请稍后重试');
+                            });
+                    }
+
+                    // 使用事件委托(通过 document.body)
+                    document.body.addEventListener('input', function(e) {
+                        if (e.target.id === 'routeInput') {
+                            console.log('Input event on routeInput:', e.target.value);
+                            const routeValue = e.target.value.trim();
+
+                            if (validationTimeout) clearTimeout(validationTimeout);
+
+                            if (!routeValue) {
+                                clearValidation();
+                                return;
+                            }
+
+                            validationTimeout = setTimeout(() => {
+                                validateRoute(routeValue);
+                            }, 500);
+                        }
+                    });
+
+                    document.body.addEventListener('blur', function(e) {
+                        if (e.target.id === 'routeInput') {
+                            console.log('Blur event on routeInput:', e.target.value);
+                            const routeValue = e.target.value.trim();
+                            if (routeValue && routeValue !== lastValidatedRoute) {
+                                validateRoute(routeValue);
+                            }
+                        }
+                    });
+
+                    // 自动生成 key
+                    if (nameInput && keyInput) {
+                        nameInput.addEventListener('blur', function() {
+                            const name = this.value.trim();
+                            const key = keyInput.value.trim();
+
+                            if (key === '' && name !== '') {
+                                let generatedKey = name.toLowerCase()
+                                    .replace(/\s+/g, '-')
+                                    .replace(/[^a-z0-9-]/g, '');
+
+                                if (generatedKey) {
+                                    keyInput.value = 'settings.' + generatedKey;
+                                    keyInput.style.borderColor = '#28a745';
+                                    setTimeout(function() {
+                                        keyInput.style.borderColor = '';
+                                    }, 3000);
+                                }
+                            }
+                        });
+                    }
+
+                    // 表单提交验证
+                    if (menuForm) {
+                        menuForm.addEventListener('submit', function(e) {
+                            const routeValue = routeInput.value.trim();
+
+                            if (routeValue) {
+                                const isLoading = validationResult && validationResult.innerHTML.includes('正在验证');
+                                const hasError = validationResult && validationResult.innerHTML.includes('✗');
+
+                                if (isLoading) {
+                                    e.preventDefault();
+                                    alert('正在验证路由,请稍候...');
+                                    return false;
+                                }
+
+                                if (hasError) {
+                                    e.preventDefault();
+                                    routeInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
+                                    routeInput.focus();
+                                    alert('路由不存在,请修正后再提交!');
+                                    return false;
+                                }
+                            }
+                        });
+                    }
+
+                    console.log('DynamicMenu: Validation initialized successfully');
                 }
-            }
-        });
-
-        // 添加提示
-        $('input[name="key"]').on('focus', function() {
-            var currentVal = $(this).val();
-            if (currentVal && currentVal.indexOf('settings.') !== 0) {
-                $(this).after(
-                    '<small class="key-warning" style="display: block; margin-top: 5px; font-size: 12px; color: #dc3545;">' +
-                    '⚠️ 警告:当前Key不以"settings."开头,菜单可能不会显示在Settings下</small>'
-                );
-
-                setTimeout(function() {
-                    $('.key-warning').remove();
-                }, 3000);
-            }
-        });
-    });*/
-</script>
-@endpush
+            })();
+        </script>
+    @endpush
 </x-admin::layouts>

+ 275 - 37
packages/Longyi/DynamicMenu/src/Resources/views/admin/menu/edit.blade.php

@@ -34,7 +34,7 @@
             </div>
 
             <div class="panel-body" style="padding: 20px;">
-                <form method="POST" action="{{ route('admin.dynamicmenu.update', $menuItem->id) }}">
+                <form method="POST" action="{{ route('admin.dynamicmenu.update', $menuItem->id) }}" id="menuForm">
                     @csrf
                     @method('PUT')
 
@@ -45,6 +45,7 @@
                         </label>
                         <input type="text"
                                name="name"
+                               id="nameInput"
                                class="control"
                                value="{{ old('name', $menuItem->name) }}"
                                required
@@ -53,16 +54,21 @@
                     </div>
 
                     {{-- Key 字段 --}}
-                    <div class="form-group">
-                        <label class="required">Key</label>
+                    <div class="form-group" style="margin-bottom: 20px;">
+                        <label class="required" style="display: block; margin-bottom: 5px; font-weight: 500;">
+                            Key <span style="color: red;">*</span>
+                        </label>
                         <input type="text"
-                            name="key"
-                            class="control"
-                            value="{{ old('key', $menuItem->key ?? '') }}"
-                            required
-                            placeholder="例如:settings.my-menu">
-                        <small class="control-info">
-                            建议以 'settings.' 开头,这样菜单会显示在 Settings 下。例如:settings.dynamic-menu
+                               name="key"
+                               id="keyInput"
+                               class="control"
+                               value="{{ old('key', $menuItem->key ?? '') }}"
+                               required
+                               placeholder="例如:settings.my-menu"
+                               style="width: 100%; padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px;">
+                        <small class="control-info" style="display: block; margin-top: 5px; font-size: 12px; color: #6c757d;">
+                            <strong style="color: #dc3545;">重要提示:</strong>
+                            如果想让菜单显示在 Settings 下,Key 必须以 <strong>settings.</strong> 开头。例如:settings.dynamic-menu
                         </small>
                     </div>
 
@@ -71,13 +77,15 @@
                         <label style="display: block; margin-bottom: 5px; font-weight: 500;">URL/路由</label>
                         <input type="text"
                                name="route"
+                               id="routeInput"
                                class="control"
                                value="{{ old('route', $menuItem->route) }}"
                                placeholder="例如:admin.dashboard"
                                style="width: 100%; padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px;">
                         <small class="control-info" style="display: block; margin-top: 5px; font-size: 12px; color: #6c757d;">
-                            可以是相对路径、完整URL或路由名称
+                            可以是路由名称(如:admin.dashboard)或完整URL
                         </small>
+                        <div id="routeValidationResult" style="margin-top: 5px; font-size: 12px;"></div>
                     </div>
 
                     {{-- 图标 --}}
@@ -97,16 +105,16 @@
                     {{-- 父级菜单 --}}
                     <div class="form-group" style="margin-bottom: 20px;">
                         <label style="display: block; margin-bottom: 5px; font-weight: 500;">父级菜单</label>
-                        <select name="parent_id" class="control" style="width: 100%; padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px;">
+                        <select name="parent_id" id="parentSelect" class="control" style="width: 100%; padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px;">
                             <option value="">作为顶级菜单</option>
                             @foreach($menuItems as $menuItemOption)
-                                @if($menuItemOption->id != $menuItem->id) {{-- 不能选择自己作为父级 --}}
-                                <option value="{{ $menuItemOption->id }}" {{ old('parent_id', $menuItem->parent_id) == $menuItemOption->id ? 'selected' : '' }}>
-                                    {{ $menuItemOption->name }}
-                                    @if($menuItemOption->children && $menuItemOption->children->count() > 0)
-                                        (有子菜单)
-                                    @endif
-                                </option>
+                                @if($menuItemOption->id != $menuItem->id)
+                                    <option value="{{ $menuItemOption->id }}" {{ old('parent_id', $menuItem->parent_id) == $menuItemOption->id ? 'selected' : '' }}>
+                                        {{ $menuItemOption->name }}
+                                        @if($menuItemOption->children && $menuItemOption->children->count() > 0)
+                                            (有子菜单)
+                                        @endif
+                                    </option>
                                 @endif
                             @endforeach
                         </select>
@@ -155,25 +163,255 @@
         </div>
     </div>
 
-    {{-- 引入必要的JavaScript --}}
+    {{-- JavaScript 代码 - 使用事件委托 --}}
     @push('scripts')
-    <script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
-    <script>
-        /*$(document).ready(function() {
-            // 自动生成key
-            $('input[name="name"]').on('blur', function() {
-                var name = $(this).val();
-                var keyInput = $('input[name="key"]');
-
-                if (keyInput.val() === '') {
-                    var key = name.toLowerCase()
-                                .replace(/\s+/g, '-')
-                                .replace(/[^a-z0-9-]/g, '');
-                    // 默认添加到 settings 下
-                    keyInput.val('settings.' + key);
+        <script>
+            (function() {
+                console.log('DynamicMenu Edit: Script loaded');
+
+                // 等待元素出现
+                let checkInterval = setInterval(function() {
+                    const routeInput = document.getElementById('routeInput');
+                    if (routeInput) {
+                        clearInterval(checkInterval);
+                        console.log('DynamicMenu Edit: routeInput found!');
+                        initValidation(routeInput);
+                    }
+                }, 100);
+
+                function initValidation(routeInput) {
+                    const validationResult = document.getElementById('routeValidationResult');
+                    const menuForm = document.getElementById('menuForm');
+                    const nameInput = document.getElementById('nameInput');
+                    const keyInput = document.getElementById('keyInput');
+
+                    let validationTimeout = null;
+                    let lastValidatedRoute = '';
+                    let routeIsValid = false;
+
+                    function getCsrfToken() {
+                        const tokenMeta = document.querySelector('meta[name="csrf-token"]');
+                        if (tokenMeta) return tokenMeta.getAttribute('content');
+                        const tokenInput = document.querySelector('input[name="_token"]');
+                        if (tokenInput) return tokenInput.value;
+                        return '';
+                    }
+
+                    function showTemporaryMessage(container, message, color) {
+                        const existingMsg = container.querySelector('.temp-msg');
+                        if (existingMsg) existingMsg.remove();
+
+                        const msgDiv = document.createElement('div');
+                        msgDiv.className = 'temp-msg';
+                        msgDiv.style.cssText = `display: block; margin-top: 5px; font-size: 12px; color: ${color};`;
+                        msgDiv.innerHTML = message;
+                        container.appendChild(msgDiv);
+
+                        setTimeout(() => {
+                            if (msgDiv.parentNode) msgDiv.remove();
+                        }, 3000);
+                    }
+
+                    function clearValidation() {
+                        if (routeInput) {
+                            routeInput.style.borderColor = '';
+                            routeInput.style.backgroundColor = '';
+                        }
+                        if (validationResult) {
+                            validationResult.innerHTML = '';
+                            validationResult.style.display = 'none';
+                        }
+                        routeIsValid = false;
+                        lastValidatedRoute = '';
+                    }
+
+                    function showLoadingState() {
+                        if (routeInput) {
+                            routeInput.style.borderColor = '#ffc107';
+                            routeInput.style.backgroundColor = '#fffbeb';
+                        }
+                        if (validationResult) {
+                            validationResult.innerHTML = '<span style="color: #ffc107;">⏳ 正在验证路由...</span>';
+                            validationResult.style.display = 'block';
+                        }
+                    }
+
+                    function showSuccessState(message) {
+                        if (routeInput) {
+                            routeInput.style.borderColor = '#28a745';
+                            routeInput.style.backgroundColor = '#f0fff4';
+                        }
+                        if (validationResult) {
+                            validationResult.innerHTML = '<span style="color: #28a745;">✓ ' + message + '</span>';
+                            validationResult.style.display = 'block';
+                        }
+                        setTimeout(() => {
+                            if (routeInput) routeInput.style.backgroundColor = '';
+                        }, 3000);
+                    }
+
+                    function showErrorState(message) {
+                        if (routeInput) {
+                            routeInput.style.borderColor = '#dc3545';
+                            routeInput.style.backgroundColor = '#fff5f5';
+                        }
+                        if (validationResult) {
+                            validationResult.innerHTML = '<span style="color: #dc3545;">✗ ' + message + '</span>';
+                            validationResult.style.display = 'block';
+                        }
+                    }
+
+                    function validateRoute(routeName) {
+                        console.log('Validating route:', routeName);
+
+                        if (!routeName) {
+                            clearValidation();
+                            return;
+                        }
+
+                        showLoadingState();
+
+                        const csrfToken = getCsrfToken();
+                        const validateUrl = '{{ route("admin.dynamicmenu.validate-route") }}';
+
+                        fetch(validateUrl, {
+                            method: 'POST',
+                            headers: {
+                                'Content-Type': 'application/json',
+                                'X-CSRF-TOKEN': csrfToken,
+                                'Accept': 'application/json'
+                            },
+                            body: JSON.stringify({ route: routeName })
+                        })
+                            .then(response => response.json())
+                            .then(data => {
+                                console.log('Validation response:', data);
+                                if (data.exists) {
+                                    routeIsValid = true;
+                                    showSuccessState(data.message);
+                                } else {
+                                    routeIsValid = false;
+                                    let errorMsg = data.message || '路由不存在';
+                                    if (data.suggestions && data.suggestions.length > 0) {
+                                        errorMsg += '<br>建议使用:<br>';
+                                        data.suggestions.forEach(s => {
+                                            errorMsg += `&nbsp;&nbsp;• ${s.name} (${s.uri})<br>`;
+                                        });
+                                    }
+                                    showErrorState(errorMsg);
+                                }
+                                lastValidatedRoute = routeName;
+                            })
+                            .catch(error => {
+                                console.error('Validation error:', error);
+                                routeIsValid = false;
+                                showErrorState('验证失败,请稍后重试');
+                            });
+                    }
+
+                    // 使用事件委托
+                    document.body.addEventListener('input', function(e) {
+                        if (e.target.id === 'routeInput') {
+                            console.log('Input event on routeInput:', e.target.value);
+                            const routeValue = e.target.value.trim();
+
+                            if (validationTimeout) clearTimeout(validationTimeout);
+
+                            if (!routeValue) {
+                                clearValidation();
+                                return;
+                            }
+
+                            validationTimeout = setTimeout(() => {
+                                validateRoute(routeValue);
+                            }, 500);
+                        }
+                    });
+
+                    document.body.addEventListener('blur', function(e) {
+                        if (e.target.id === 'routeInput') {
+                            console.log('Blur event on routeInput:', e.target.value);
+                            const routeValue = e.target.value.trim();
+                            if (routeValue && routeValue !== lastValidatedRoute) {
+                                validateRoute(routeValue);
+                            }
+                        }
+                    });
+
+                    // 自动生成 key(仅当 key 为空时)
+                    if (nameInput && keyInput) {
+                        nameInput.addEventListener('blur', function() {
+                            const name = this.value.trim();
+                            const key = keyInput.value.trim();
+
+                            if (key === '' && name !== '') {
+                                let generatedKey = name.toLowerCase()
+                                    .replace(/\s+/g, '-')
+                                    .replace(/[^a-z0-9-]/g, '');
+
+                                if (generatedKey) {
+                                    keyInput.value = 'settings.' + generatedKey;
+                                    keyInput.style.borderColor = '#28a745';
+                                    showTemporaryMessage(keyInput.parentElement, '✓ 已自动生成Key: settings.' + generatedKey, '#28a745');
+                                    setTimeout(() => {
+                                        keyInput.style.borderColor = '';
+                                    }, 3000);
+                                }
+                            }
+                        });
+                    }
+
+                    // Key 格式提示
+                    if (keyInput) {
+                        keyInput.addEventListener('focus', function() {
+                            const currentVal = this.value;
+                            if (currentVal && !currentVal.startsWith('settings.')) {
+                                showTemporaryMessage(this.parentElement,
+                                    '⚠️ 警告:当前Key不以"settings."开头,菜单可能不会显示在Settings下',
+                                    '#dc3545');
+                            }
+                        });
+                    }
+
+                    // 表单提交验证
+                    if (menuForm) {
+                        menuForm.addEventListener('submit', function(e) {
+                            const routeValue = routeInput.value.trim();
+
+                            if (routeValue) {
+                                const isLoading = validationResult && validationResult.innerHTML.includes('正在验证');
+                                const hasError = validationResult && validationResult.innerHTML.includes('✗');
+
+                                if (isLoading) {
+                                    e.preventDefault();
+                                    alert('正在验证路由,请稍候...');
+                                    return false;
+                                }
+
+                                if (hasError) {
+                                    e.preventDefault();
+                                    routeInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
+                                    routeInput.focus();
+                                    alert('路由不存在,请修正后再提交!\n\n提示:\n' +
+                                        '1. 检查路由名称是否正确\n' +
+                                        '2. 确保路由已在 routes/web.php 中定义\n' +
+                                        '3. 可以使用完整URL(如:https://example.com)跳过验证');
+                                    return false;
+                                }
+                            }
+                        });
+                    }
+
+                    // 如果有初始值,验证一下
+                    if (routeInput.value.trim()) {
+                        setTimeout(() => {
+                            validateRoute(routeInput.value.trim());
+                        }, 500);
+                    }
+
+                    console.log('DynamicMenu Edit: Validation initialized successfully');
                 }
-            });
-        });*/
-    </script>
+            })();
+        </script>
     @endpush
 </x-admin::layouts>

+ 16 - 12
packages/Longyi/DynamicMenu/src/Routes/admin-routes.php

@@ -5,53 +5,57 @@ use Longyi\DynamicMenu\Http\Controllers\MenuController;
 
 // 关键:使用正确的路由组
 Route::group(['middleware' => ['web', 'admin'], 'prefix' => 'admin'], function () {
-    
-    
-    
+
+
+
     // 菜单管理路由 - 注意这里不要重复加 'admin' 前缀
     Route::get('dynamicmenu', [MenuController::class, 'index'])
         ->name('admin.dynamicmenu.index')
         ->defaults('_config', [
             'view' => 'dynamicmenu::admin.menu.index'
         ]);
-    
+
     Route::get('dynamicmenu/create', [MenuController::class, 'create'])
         ->name('admin.dynamicmenu.create')
         ->defaults('_config', [
             'view' => 'dynamicmenu::admin.menu.create'
         ]);
-    
+
     Route::post('dynamicmenu', [MenuController::class, 'store'])
         ->name('admin.dynamicmenu.store')
         ->defaults('_config', [
             'redirect' => 'admin.dynamicmenu.index'
         ]);
-    
+
     Route::get('dynamicmenu/{id}/edit', [MenuController::class, 'edit'])
         ->name('admin.dynamicmenu.edit')
         ->defaults('_config', [
             'view' => 'dynamicmenu::admin.menu.edit'
         ]);
-    
+
     Route::put('dynamicmenu/{id}', [MenuController::class, 'update'])
         ->name('admin.dynamicmenu.update')
         ->defaults('_config', [
             'redirect' => 'admin.dynamicmenu.index'
         ]);
-    
+
     Route::delete('dynamicmenu/{id}', [MenuController::class, 'destroy'])
         ->name('admin.dynamicmenu.delete');
-    
+
     // 权限管理路由
     Route::get('dynamicmenu-permissions', [MenuController::class, 'permission'])
         ->name('admin.dynamicmenu.permission')
         ->defaults('_config', [
             'view' => 'dynamicmenu::admin.permission.index'
         ]);
-    
+
     Route::post('dynamicmenu-permissions', [MenuController::class, 'updatePermission'])
         ->name('admin.dynamicmenu.permission.update');
-    
+
     Route::post('dynamicmenu-permissions/get', [MenuController::class, 'getRolePermissions'])
         ->name('admin.dynamicmenu.permission.get');
-});
+
+    // 路由验证接口
+    Route::post('dynamicmenu/validate-route', [MenuController::class, 'validateRoute'])
+        ->name('admin.dynamicmenu.validate-route');
+});