bianjunhui 3 дней назад
Родитель
Сommit
21f1d7a113
24 измененных файлов с 1500 добавлено и 92 удалено
  1. 94 92
      bootstrap/cache/services.php
  2. 1 0
      bootstrap/providers.php
  3. 1 0
      composer.json
  4. 169 0
      packages/Longyi/FrontMenu/README.md
  5. 30 0
      packages/Longyi/FrontMenu/composer.json
  6. 25 0
      packages/Longyi/FrontMenu/src/Config/acl.php
  7. 11 0
      packages/Longyi/FrontMenu/src/Config/menu.php
  8. 37 0
      packages/Longyi/FrontMenu/src/Console/Commands/InitializeFrontMenu.php
  9. 45 0
      packages/Longyi/FrontMenu/src/Database/Migrations/2026_08_28_000001_create_front_menu_items_table.php
  10. 61 0
      packages/Longyi/FrontMenu/src/Database/Seeders/FrontMenuItemSeeder.php
  11. 140 0
      packages/Longyi/FrontMenu/src/Http/Controllers/FrontMenuController.php
  12. 65 0
      packages/Longyi/FrontMenu/src/Models/FrontMenuItem.php
  13. 46 0
      packages/Longyi/FrontMenu/src/Providers/FrontMenuServiceProvider.php
  14. 75 0
      packages/Longyi/FrontMenu/src/Repositories/FrontMenuItemRepository.php
  15. 56 0
      packages/Longyi/FrontMenu/src/Resources/lang/en/app.php
  16. 56 0
      packages/Longyi/FrontMenu/src/Resources/lang/zh_CN/app.php
  17. 133 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/create.blade.php
  18. 138 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/edit.blade.php
  19. 45 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/index.blade.php
  20. 54 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/partials/tree.blade.php
  21. 39 0
      packages/Longyi/FrontMenu/src/Routes/admin-routes.php
  22. 130 0
      packages/Webkul/BagistoApi/src/Models/FrontMenuItem.php
  23. 2 0
      packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php
  24. 47 0
      packages/Webkul/BagistoApi/src/Resolver/FrontMenuTreeResolver.php

+ 94 - 92
bootstrap/cache/services.php

@@ -55,52 +55,53 @@
     51 => 'Webkul\\Admin\\Providers\\AdminServiceProvider',
     52 => 'Longyi\\Core\\Providers\\LongyiCoreServiceProvider',
     53 => 'Longyi\\DynamicMenu\\Providers\\DynamicMenuServiceProvider',
-    54 => 'Longyi\\Email\\Providers\\EmailServiceProvider',
-    55 => 'Longyi\\ImageUpload\\Providers\\ImageUploadServiceProvider',
-    56 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
-    57 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
-    58 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
-    59 => 'Longyi\\ShippingInsurance\\Providers\\ShippingInsuranceServiceProvider',
-    60 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
-    61 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
-    62 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
-    63 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
-    64 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
-    65 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
-    66 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
-    67 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
-    68 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
-    69 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
-    70 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
-    71 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
-    72 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
-    73 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
-    74 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
-    75 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
-    76 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
-    77 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
-    78 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
-    79 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
-    80 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
-    81 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
-    82 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
-    83 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
-    84 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
-    85 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
-    86 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
-    87 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
-    88 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
-    89 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
-    90 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
-    91 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
-    92 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
-    93 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
-    94 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
-    95 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
-    96 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
-    97 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
-    98 => 'Webkul\\User\\Providers\\UserServiceProvider',
-    99 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
+    54 => 'Longyi\\FrontMenu\\Providers\\FrontMenuServiceProvider',
+    55 => 'Longyi\\Email\\Providers\\EmailServiceProvider',
+    56 => 'Longyi\\ImageUpload\\Providers\\ImageUploadServiceProvider',
+    57 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
+    58 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
+    59 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
+    60 => 'Longyi\\ShippingInsurance\\Providers\\ShippingInsuranceServiceProvider',
+    61 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
+    62 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
+    63 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
+    64 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
+    65 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
+    66 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
+    67 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
+    68 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
+    69 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
+    70 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
+    71 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    72 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    73 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    74 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    75 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    76 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    77 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    78 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    79 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    80 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    81 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    82 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    83 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    84 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    85 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    86 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    87 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    88 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    89 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    90 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    91 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    92 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    93 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    94 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    95 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    96 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    97 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    98 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    99 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    100 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'eager' => 
   array (
@@ -141,52 +142,53 @@
     34 => 'Webkul\\Admin\\Providers\\AdminServiceProvider',
     35 => 'Longyi\\Core\\Providers\\LongyiCoreServiceProvider',
     36 => 'Longyi\\DynamicMenu\\Providers\\DynamicMenuServiceProvider',
-    37 => 'Longyi\\Email\\Providers\\EmailServiceProvider',
-    38 => 'Longyi\\ImageUpload\\Providers\\ImageUploadServiceProvider',
-    39 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
-    40 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
-    41 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
-    42 => 'Longyi\\ShippingInsurance\\Providers\\ShippingInsuranceServiceProvider',
-    43 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
-    44 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
-    45 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
-    46 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
-    47 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
-    48 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
-    49 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
-    50 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
-    51 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
-    52 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
-    53 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
-    54 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
-    55 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
-    56 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
-    57 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
-    58 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
-    59 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
-    60 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
-    61 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
-    62 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
-    63 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
-    64 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
-    65 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
-    66 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
-    67 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
-    68 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
-    69 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
-    70 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
-    71 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
-    72 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
-    73 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
-    74 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
-    75 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
-    76 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
-    77 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
-    78 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
-    79 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
-    80 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
-    81 => 'Webkul\\User\\Providers\\UserServiceProvider',
-    82 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
+    37 => 'Longyi\\FrontMenu\\Providers\\FrontMenuServiceProvider',
+    38 => 'Longyi\\Email\\Providers\\EmailServiceProvider',
+    39 => 'Longyi\\ImageUpload\\Providers\\ImageUploadServiceProvider',
+    40 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
+    41 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
+    42 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
+    43 => 'Longyi\\ShippingInsurance\\Providers\\ShippingInsuranceServiceProvider',
+    44 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
+    45 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
+    46 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
+    47 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
+    48 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
+    49 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
+    50 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
+    51 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
+    52 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
+    53 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
+    54 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    55 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    56 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    57 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    58 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    59 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    60 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    61 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    62 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    63 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    64 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    65 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    66 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    67 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    68 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    69 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    70 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    71 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    72 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    73 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    74 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    75 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    76 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    77 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    78 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    79 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    80 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    81 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    82 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    83 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'deferred' => 
   array (

+ 1 - 0
bootstrap/providers.php

@@ -12,6 +12,7 @@ return [
     Webkul\Admin\Providers\AdminServiceProvider::class,
     Longyi\Core\Providers\LongyiCoreServiceProvider::class,
     Longyi\DynamicMenu\Providers\DynamicMenuServiceProvider::class,
+    Longyi\FrontMenu\Providers\FrontMenuServiceProvider::class,
     Longyi\Email\Providers\EmailServiceProvider::class,
     Longyi\ImageUpload\Providers\ImageUploadServiceProvider::class,
     Longyi\RewardPoints\Providers\RewardPointsServiceProvider::class,

+ 1 - 0
composer.json

@@ -79,6 +79,7 @@
             "Database\\Seeders\\": "database/seeders/",
             "Longyi\\Core\\": "packages/Longyi/Core/src/",
             "Longyi\\DynamicMenu\\": "packages/Longyi/DynamicMenu/src/",
+            "Longyi\\FrontMenu\\": "packages/Longyi/FrontMenu/src/",
             "Longyi\\Email\\": "packages/Longyi/Email/src/",
             "Longyi\\ImageUpload\\": "packages/Longyi/ImageUpload/src/",
             "Longyi\\RewardPoints\\": "packages/Longyi/RewardPoints/src/",

+ 169 - 0
packages/Longyi/FrontMenu/README.md

@@ -0,0 +1,169 @@
+# FrontMenu - 前端三级导航菜单插件
+
+为 Bagisto 提供前台(Shop)三级导航菜单管理,支持在后台 Settings 菜单下进行菜单项的增删改查,并通过 GraphQL 接口对外提供数据查询。
+
+## 功能特性
+
+- 后台 Settings 菜单下新增「前端导航菜单」管理入口
+- 菜单项字段:`name`(名称)、`slug`(唯一标识)、`url`(链接)、`image`(图片)、`banner`(横幅)、`status`(状态)、`sort_order`(排序)
+- 支持三级菜单层级(父级/子级/孙级)
+- 级联删除(删除父级时自动删除子级)
+- GraphQL 接口提供单条、集合、树形三种查询
+
+## 依赖
+
+- PHP ^8.1|^8.2
+- Laravel ^10.0|^11.0
+- Bagisto 2.x
+- api-platform(用于 GraphQL,项目已内置)
+
+## 安装步骤
+
+### 1. 注册 Provider
+
+`bootstrap/providers.php` 中已添加:
+
+```php
+Longyi\FrontMenu\Providers\FrontMenuServiceProvider::class,
+```
+
+### 2. 运行数据库迁移
+
+```bash
+php artisan migrate
+```
+
+### 3. 初始化默认菜单(可选)
+
+```bash
+php artisan front-menu:init
+```
+
+### 4. 清理缓存
+
+```bash
+php artisan cache:clear
+php artisan config:clear
+php artisan view:clear
+php artisan route:clear
+```
+
+### 5. 授权角色
+
+在后台 `Settings → Roles` 中为对应角色勾选「前端导航菜单」权限,菜单即可在侧边栏 Settings 下显示。
+
+## GraphQL 查询
+
+GraphQL 端点:`/api/graphql`(需携带 storefront key)。
+
+### 树形查询(推荐,返回启用状态的三级菜单树)
+
+```graphql
+{
+  frontMenuTrees {
+    edges {
+      node {
+        id
+        name
+        slug
+        url
+        image
+        banner
+        sortOrder
+        status
+        children {
+          edges {
+            node {
+              id
+              name
+              slug
+              url
+              children {
+                edges {
+                  node {
+                    id
+                    name
+                    slug
+                    url
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+```
+
+### 单条查询
+
+```graphql
+{
+  frontMenu(id: "1") {
+    id
+    name
+    slug
+    url
+    image
+    banner
+    sortOrder
+    status
+  }
+}
+```
+
+### 集合查询
+
+```graphql
+{
+  frontMenus {
+    edges {
+      node {
+        id
+        name
+        slug
+        url
+      }
+    }
+  }
+}
+```
+
+## 目录结构
+
+```
+packages/Longyi/FrontMenu/
+├── composer.json
+├── README.md
+└── src/
+    ├── Config/
+    │   ├── menu.php          # 后台菜单配置
+    │   └── acl.php           # ACL 权限配置
+    ├── Console/
+    │   └── Commands/
+    │       └── InitializeFrontMenu.php
+    ├── Database/
+    │   ├── Migrations/
+    │   │   └── 2026_08_28_000001_create_front_menu_items_table.php
+    │   └── Seeders/
+    │       └── FrontMenuItemSeeder.php
+    ├── Http/
+    │   └── Controllers/
+    │       └── FrontMenuController.php
+    ├── Models/
+    │   └── FrontMenuItem.php
+    ├── Providers/
+    │   └── FrontMenuServiceProvider.php
+    ├── Repositories/
+    │   └── FrontMenuItemRepository.php
+    ├── Resources/
+    │   ├── lang/
+    │   │   ├── en/app.php
+    │   │   └── zh_CN/app.php
+    │   └── views/
+    │       └── admin/menu/
+    └── Routes/
+        └── admin-routes.php
+```

+ 30 - 0
packages/Longyi/FrontMenu/composer.json

@@ -0,0 +1,30 @@
+{
+    "name": "longyi/front-menu",
+    "description": "Frontend three-level navigation menu management for Bagisto",
+    "type": "library",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Longyi Team",
+            "email": "dev@longyi.com"
+        }
+    ],
+    "minimum-stability": "dev",
+    "prefer-stable": true,
+    "require": {
+        "php": "^8.1|^8.2",
+        "illuminate/support": "^10.0|^11.0"
+    },
+    "autoload": {
+        "psr-4": {
+            "Longyi\\FrontMenu\\": "src/"
+        }
+    },
+    "extra": {
+        "laravel": {
+            "providers": [
+                "Longyi\\FrontMenu\\Providers\\FrontMenuServiceProvider"
+            ]
+        }
+    }
+}

+ 25 - 0
packages/Longyi/FrontMenu/src/Config/acl.php

@@ -0,0 +1,25 @@
+<?php
+
+return [
+    [
+        'key'   => 'settings.front_menu',
+        'name'  => 'frontmenu::app.admin.menu.title',
+        'route' => 'admin.frontmenu.index',
+        'sort'  => 12,
+    ], [
+        'key'   => 'settings.front_menu.create',
+        'name'  => 'admin::app.acl.create',
+        'route' => 'admin.frontmenu.create',
+        'sort'  => 1,
+    ], [
+        'key'   => 'settings.front_menu.edit',
+        'name'  => 'admin::app.acl.edit',
+        'route' => 'admin.frontmenu.edit',
+        'sort'  => 2,
+    ], [
+        'key'   => 'settings.front_menu.delete',
+        'name'  => 'admin::app.acl.delete',
+        'route' => 'admin.frontmenu.delete',
+        'sort'  => 3,
+    ],
+];

+ 11 - 0
packages/Longyi/FrontMenu/src/Config/menu.php

@@ -0,0 +1,11 @@
+<?php
+
+return [
+    [
+        'key'   => 'settings.front_menu',
+        'name'  => 'frontmenu::app.admin.menu.title',
+        'route' => 'admin.frontmenu.index',
+        'sort'  => 12,
+        'icon'  => 'icon-menu',
+    ],
+];

+ 37 - 0
packages/Longyi/FrontMenu/src/Console/Commands/InitializeFrontMenu.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Longyi\FrontMenu\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Schema;
+use Longyi\FrontMenu\Database\Seeders\FrontMenuItemSeeder;
+
+class InitializeFrontMenu extends Command
+{
+    protected $signature = 'front-menu:init';
+
+    protected $description = 'Initialize the front navigation menu with default items';
+
+    public function handle(): int
+    {
+        $this->info('Initializing front navigation menu...');
+
+        if (! Schema::hasTable('front_menu_items')) {
+            $this->error('Table "front_menu_items" not found. Please run "php artisan migrate" first.');
+
+            return 1;
+        }
+
+        try {
+            $this->call(FrontMenuItemSeeder::class);
+
+            $this->info('Front navigation menu initialized successfully.');
+
+            return 0;
+        } catch (\Exception $e) {
+            $this->error('Failed to initialize front menu: ' . $e->getMessage());
+
+            return 1;
+        }
+    }
+}

+ 45 - 0
packages/Longyi/FrontMenu/src/Database/Migrations/2026_08_28_000001_create_front_menu_items_table.php

@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('front_menu_items', function (Blueprint $table) {
+            $table->id();
+            $table->string('name');
+            $table->string('slug')->unique();
+            $table->string('url')->nullable();
+            $table->string('image')->nullable();
+            $table->string('banner')->nullable();
+            $table->unsignedBigInteger('parent_id')->nullable();
+            $table->integer('sort_order')->default(0);
+            $table->boolean('status')->default(true);
+            $table->unsignedBigInteger('created_by')->nullable();
+            $table->timestamps();
+
+            $table->index('parent_id', 'idx_front_menu_parent');
+            $table->index('sort_order', 'idx_front_menu_order');
+            $table->index('status', 'idx_front_menu_status');
+
+            $table->foreign('parent_id')
+                ->references('id')
+                ->on('front_menu_items')
+                ->onDelete('cascade');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('front_menu_items');
+    }
+};

+ 61 - 0
packages/Longyi/FrontMenu/src/Database/Seeders/FrontMenuItemSeeder.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace Longyi\FrontMenu\Database\Seeders;
+
+use Illuminate\Database\Seeder;
+use Longyi\FrontMenu\Models\FrontMenuItem;
+
+class FrontMenuItemSeeder extends Seeder
+{
+    public function run(): void
+    {
+        if (FrontMenuItem::count() > 0) {
+            return;
+        }
+
+        $home = FrontMenuItem::create([
+            'name'       => '首页',
+            'slug'       => 'home',
+            'url'        => '/',
+            'sort_order' => 1,
+            'status'     => true,
+        ]);
+
+        $categories = FrontMenuItem::create([
+            'name'       => '分类导航',
+            'slug'       => 'categories',
+            'url'        => '/categories',
+            'sort_order' => 2,
+            'status'     => true,
+        ]);
+
+        FrontMenuItem::create([
+            'name'       => '服装',
+            'slug'       => 'clothing',
+            'url'        => '/categories/clothing',
+            'parent_id'  => $categories->id,
+            'sort_order' => 1,
+            'status'     => true,
+        ]);
+
+        $electronics = FrontMenuItem::create([
+            'name'       => '数码电子',
+            'slug'       => 'electronics',
+            'url'        => '/categories/electronics',
+            'parent_id'  => $categories->id,
+            'sort_order' => 2,
+            'status'     => true,
+        ]);
+
+        FrontMenuItem::create([
+            'name'       => '手机',
+            'slug'       => 'phones',
+            'url'        => '/categories/electronics/phones',
+            'parent_id'  => $electronics->id,
+            'sort_order' => 1,
+            'status'     => true,
+        ]);
+
+        $home->save();
+    }
+}

+ 140 - 0
packages/Longyi/FrontMenu/src/Http/Controllers/FrontMenuController.php

@@ -0,0 +1,140 @@
+<?php
+
+namespace Longyi\FrontMenu\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Routing\Controller;
+use Longyi\FrontMenu\Models\FrontMenuItem;
+use Longyi\FrontMenu\Repositories\FrontMenuItemRepository;
+
+class FrontMenuController extends Controller
+{
+    protected $repository;
+
+    public function __construct(FrontMenuItemRepository $repository)
+    {
+        $this->repository = $repository;
+        $this->_config = request('_config');
+    }
+
+    /**
+     * Display the menu tree list.
+     */
+    public function index()
+    {
+        $menuItems = $this->repository->getMenuTree();
+
+        return view($this->_config['view'], compact('menuItems'));
+    }
+
+    /**
+     * Show the create form.
+     */
+    public function create()
+    {
+        $menuItems = $this->repository->getMenuTree();
+
+        return view($this->_config['view'], compact('menuItems'));
+    }
+
+    /**
+     * Store a new menu item.
+     */
+    public function store(Request $request)
+    {
+        $request->validate([
+            'name'       => 'required|string|max:255',
+            'slug'       => 'required|string|max:255|unique:front_menu_items,slug',
+            'url'        => 'nullable|string|max:2048',
+            'image'      => 'nullable|string|max:2048',
+            'banner'     => 'nullable|string|max:2048',
+            'parent_id'  => 'nullable|exists:front_menu_items,id',
+            'sort_order' => 'nullable|integer',
+            'status'     => 'nullable|boolean',
+        ]);
+
+        $this->repository->create($this->prepareData($request));
+
+        session()->flash('success', __('frontmenu::app.admin.menu.created'));
+
+        return redirect()->route($this->_config['redirect']);
+    }
+
+    /**
+     * Show the edit form.
+     */
+    public function edit($id)
+    {
+        $menuItem = $this->repository->findOrFail($id);
+        $menuItems = $this->repository->getMenuTree();
+
+        return view($this->_config['view'], compact('menuItem', 'menuItems'));
+    }
+
+    /**
+     * Update an existing menu item.
+     */
+    public function update(Request $request, $id)
+    {
+        $request->validate([
+            'name'       => 'required|string|max:255',
+            'slug'       => 'required|string|max:255|unique:front_menu_items,slug,' . $id,
+            'url'        => 'nullable|string|max:2048',
+            'image'      => 'nullable|string|max:2048',
+            'banner'     => 'nullable|string|max:2048',
+            'parent_id'  => 'nullable|exists:front_menu_items,id',
+            'sort_order' => 'nullable|integer',
+            'status'     => 'nullable|boolean',
+        ]);
+
+        // Prevent a menu item from becoming its own parent or a descendant's child.
+        if ($request->filled('parent_id') && $request->input('parent_id') == $id) {
+            session()->flash('error', __('frontmenu::app.admin.menu.self-parent'));
+
+            return redirect()->back()->withInput();
+        }
+
+        $this->repository->update($this->prepareData($request), $id);
+
+        session()->flash('success', __('frontmenu::app.admin.menu.updated'));
+
+        return redirect()->route($this->_config['redirect']);
+    }
+
+    /**
+     * Delete a menu item (and its children via cascade).
+     */
+    public function destroy($id)
+    {
+        $menuItem = $this->repository->findOrFail($id);
+
+        $menuItem->delete();
+
+        session()->flash('success', __('frontmenu::app.admin.menu.deleted'));
+
+        return redirect()->route($this->_config['redirect']);
+    }
+
+    /**
+     * Normalize request data before persisting.
+     */
+    protected function prepareData(Request $request): array
+    {
+        $data = $request->only([
+            'name',
+            'slug',
+            'url',
+            'image',
+            'banner',
+            'parent_id',
+            'sort_order',
+        ]);
+
+        $data['parent_id'] = $request->filled('parent_id') ? (int) $request->input('parent_id') : null;
+        $data['sort_order'] = $request->input('sort_order', 0);
+        $data['status'] = $request->has('status') ? (bool) $request->input('status') : true;
+        $data['created_by'] = auth()->guard('admin')->user()->id ?? null;
+
+        return $data;
+    }
+}

+ 65 - 0
packages/Longyi/FrontMenu/src/Models/FrontMenuItem.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace Longyi\FrontMenu\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Cache;
+
+class FrontMenuItem extends Model
+{
+    protected $table = 'front_menu_items';
+
+    protected $fillable = [
+        'name',
+        'slug',
+        'url',
+        'image',
+        'banner',
+        'parent_id',
+        'sort_order',
+        'status',
+        'created_by',
+    ];
+
+    protected $casts = [
+        'status'     => 'boolean',
+        'sort_order' => 'integer',
+        'parent_id'  => 'integer',
+    ];
+
+    /**
+     * Boot the model and clear front menu cache on changes.
+     */
+    protected static function boot(): void
+    {
+        parent::boot();
+
+        static::saved(function () {
+            Cache::forget('front_menu_tree');
+            Cache::forget('front_menu_items');
+        });
+
+        static::deleted(function () {
+            Cache::forget('front_menu_tree');
+            Cache::forget('front_menu_items');
+        });
+    }
+
+    /**
+     * Get the parent menu item.
+     */
+    public function parent()
+    {
+        return $this->belongsTo(self::class, 'parent_id');
+    }
+
+    /**
+     * Get the child menu items.
+     */
+    public function children()
+    {
+        return $this->hasMany(self::class, 'parent_id')
+            ->orderBy('sort_order')
+            ->orderBy('id');
+    }
+}

+ 46 - 0
packages/Longyi/FrontMenu/src/Providers/FrontMenuServiceProvider.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace Longyi\FrontMenu\Providers;
+
+use Illuminate\Support\ServiceProvider;
+
+class FrontMenuServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        // Merge the front menu admin item into the `menu.admin` config so it
+        // appears under the existing "Settings" menu (key prefixed with "settings.").
+        $this->mergeConfigFrom(__DIR__ . '/../Config/menu.php', 'menu.admin');
+
+        // Register ACL permissions so the menu item is visible to admin users.
+        $this->mergeConfigFrom(__DIR__ . '/../Config/acl.php', 'acl');
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        $this->loadRoutesFrom(__DIR__ . '/../Routes/admin-routes.php');
+        $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'frontmenu');
+        $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'frontmenu');
+        $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
+
+        $this->publishes([
+            __DIR__ . '/../Resources/views' => resource_path('views/vendor/longyi/frontmenu'),
+        ], 'frontmenu-views');
+
+        $this->publishes([
+            __DIR__ . '/../Database/Seeders' => database_path('seeders'),
+        ], 'frontmenu-seeds');
+
+        if ($this->app->runningInConsole()) {
+            $this->commands([
+                \Longyi\FrontMenu\Console\Commands\InitializeFrontMenu::class,
+            ]);
+        }
+    }
+}

+ 75 - 0
packages/Longyi/FrontMenu/src/Repositories/FrontMenuItemRepository.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace Longyi\FrontMenu\Repositories;
+
+use Illuminate\Support\Facades\Cache;
+use Longyi\FrontMenu\Models\FrontMenuItem;
+use Webkul\Core\Eloquent\Repository;
+
+class FrontMenuItemRepository extends Repository
+{
+    public function model()
+    {
+        return FrontMenuItem::class;
+    }
+
+    /**
+     * Build a hierarchical tree (up to three levels) of all menu items.
+     */
+    public function getMenuTree(bool $onlyEnabled = false)
+    {
+        $query = $this->model->newQuery();
+
+        if ($onlyEnabled) {
+            $query->where('status', 1);
+        }
+
+        $items = $query->orderBy('parent_id')
+            ->orderBy('sort_order')
+            ->orderBy('id')
+            ->get();
+
+        return collect($this->buildTree($items));
+    }
+
+    /**
+     * Get the enabled front menu tree, cached for the frontend / GraphQL.
+     */
+    public function getEnabledMenuTree()
+    {
+        return Cache::remember('front_menu_tree', 3600, function () {
+            return $this->getMenuTree(true);
+        });
+    }
+
+    /**
+     * Recursively build the menu tree.
+     */
+    protected function buildTree($items, ?int $parentId = null)
+    {
+        $tree = [];
+
+        foreach ($items as $item) {
+            $itemParentId = $item->parent_id === null ? null : (int) $item->parent_id;
+
+            if ($itemParentId === $parentId) {
+                $children = $this->buildTree($items, (int) $item->id);
+
+                $item->setRelation('children', collect($children));
+
+                $tree[] = $item;
+            }
+        }
+
+        return $tree;
+    }
+
+    /**
+     * Clear the front menu cache.
+     */
+    public function clearCache(): void
+    {
+        Cache::forget('front_menu_tree');
+        Cache::forget('front_menu_items');
+    }
+}

+ 56 - 0
packages/Longyi/FrontMenu/src/Resources/lang/en/app.php

@@ -0,0 +1,56 @@
+<?php
+
+return [
+    'admin' => [
+        'menu' => [
+            'title' => 'Front Navigation Menu',
+
+            'created' => 'Menu item created successfully',
+            'updated' => 'Menu item updated successfully',
+            'deleted' => 'Menu item deleted successfully',
+            'self-parent' => 'A menu item cannot be its own parent',
+
+            'add-btn' => 'Add Menu Item',
+            'back-btn' => 'Back',
+            'save-btn' => 'Save Menu Item',
+            'cancel-btn' => 'Cancel',
+            'edit' => 'Edit',
+            'delete' => 'Delete',
+            'delete-confirm' => 'Are you sure you want to delete this item? Its children will also be deleted.',
+
+            'add-title' => 'Add Menu Item',
+            'edit-title' => 'Edit Menu Item',
+            'basic-info' => 'Basic Information',
+
+            'name' => 'Name',
+            'name-placeholder' => 'e.g. Home',
+
+            'slug' => 'Slug',
+            'slug-placeholder' => 'e.g. home',
+            'slug-info' => 'Unique identifier used for frontend and API queries',
+
+            'url' => 'URL',
+            'url-placeholder' => 'e.g. /categories or https://example.com',
+            'url-info' => 'Can be a relative path or a full URL',
+
+            'image' => 'Image',
+            'image-placeholder' => 'Image URL or upload path',
+
+            'banner' => 'Banner',
+            'banner-placeholder' => 'Banner image URL or upload path',
+
+            'parent' => 'Parent Menu',
+            'parent-none' => 'As top level menu',
+            'has-children' => 'Has children',
+
+            'sort-order' => 'Sort Order',
+            'sort-order-info' => 'Lower number appears first',
+
+            'status' => 'Status',
+            'status-active' => 'Active',
+            'status-inactive' => 'Inactive',
+
+            'disabled' => 'Disabled',
+        ],
+    ],
+];

+ 56 - 0
packages/Longyi/FrontMenu/src/Resources/lang/zh_CN/app.php

@@ -0,0 +1,56 @@
+<?php
+
+return [
+    'admin' => [
+        'menu' => [
+            'title' => '前端导航菜单',
+
+            'created' => '菜单项创建成功',
+            'updated' => '菜单项更新成功',
+            'deleted' => '菜单项删除成功',
+            'self-parent' => '不能将菜单项设为自身的子菜单',
+
+            'add-btn' => '添加菜单项',
+            'back-btn' => '返回列表',
+            'save-btn' => '保存菜单项',
+            'cancel-btn' => '取消',
+            'edit' => '编辑',
+            'delete' => '删除',
+            'delete-confirm' => '确定要删除这个菜单项吗?删除后其子菜单也将一并删除。',
+
+            'add-title' => '添加菜单项',
+            'edit-title' => '编辑菜单项',
+            'basic-info' => '基本信息',
+
+            'name' => '名称',
+            'name-placeholder' => '例如:首页',
+
+            'slug' => 'Slug',
+            'slug-placeholder' => '例如:home',
+            'slug-info' => '唯一标识,用于前端与接口查询',
+
+            'url' => 'URL',
+            'url-placeholder' => '例如:/categories 或 https://example.com',
+            'url-info' => '可以是相对路径或完整 URL',
+
+            'image' => '图片',
+            'image-placeholder' => '图片 URL 或上传路径',
+
+            'banner' => '横幅 Banner',
+            'banner-placeholder' => '横幅图片 URL 或上传路径',
+
+            'parent' => '父级菜单',
+            'parent-none' => '作为一级菜单',
+            'has-children' => '有子菜单',
+
+            'sort-order' => '排序',
+            'sort-order-info' => '数字越小越靠前',
+
+            'status' => '状态',
+            'status-active' => '启用',
+            'status-inactive' => '禁用',
+
+            'disabled' => '已禁用',
+        ],
+    ],
+];

+ 133 - 0
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/create.blade.php

@@ -0,0 +1,133 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('frontmenu::app.admin.menu.add-title')
+    </x-slot>
+
+    <div class="content">
+        <div class="flex items-center justify-between gap-4 max-sm:flex-wrap">
+            <p class="py-[11px] text-xl font-bold text-gray-800 dark:text-white">
+                @lang('frontmenu::app.admin.menu.add-title')
+            </p>
+        </div>
+
+        @if ($errors->any())
+            <div class="mt-4 rounded bg-red-50 px-4 py-3 text-red-700 dark:bg-red-950 dark:text-red-300">
+                <ul class="list-disc pl-5">
+                    @foreach ($errors->all() as $error)
+                        <li>{{ $error }}</li>
+                    @endforeach
+                </ul>
+            </div>
+        @endif
+
+        <form method="POST" action="{{ route('admin.frontmenu.store') }}" class="mt-8">
+            @csrf
+
+            <div class="grid grid-cols-1 gap-6 md:grid-cols-2">
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="name">
+                        @lang('frontmenu::app.admin.menu.name') <span class="text-red-500">*</span>
+                    </label>
+                    <input type="text" name="name" id="name" value="{{ old('name') }}" required
+                           placeholder="@lang('frontmenu::app.admin.menu.name-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="slug">
+                        @lang('frontmenu::app.admin.menu.slug') <span class="text-red-500">*</span>
+                    </label>
+                    <input type="text" name="slug" id="slug" value="{{ old('slug') }}" required
+                           placeholder="@lang('frontmenu::app.admin.menu.slug-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                    <small class="mt-1 block text-xs text-gray-500">@lang('frontmenu::app.admin.menu.slug-info')</small>
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="url">
+                        @lang('frontmenu::app.admin.menu.url')
+                    </label>
+                    <input type="text" name="url" id="url" value="{{ old('url') }}"
+                           placeholder="@lang('frontmenu::app.admin.menu.url-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                    <small class="mt-1 block text-xs text-gray-500">@lang('frontmenu::app.admin.menu.url-info')</small>
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="image">
+                        @lang('frontmenu::app.admin.menu.image')
+                    </label>
+                    <input type="text" name="image" id="image" value="{{ old('image') }}"
+                           placeholder="@lang('frontmenu::app.admin.menu.image-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="banner">
+                        @lang('frontmenu::app.admin.menu.banner')
+                    </label>
+                    <input type="text" name="banner" id="banner" value="{{ old('banner') }}"
+                           placeholder="@lang('frontmenu::app.admin.menu.banner-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="parent_id">
+                        @lang('frontmenu::app.admin.menu.parent')
+                    </label>
+                    <select name="parent_id" id="parent_id"
+                            class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                        <option value="">@lang('frontmenu::app.admin.menu.parent-none')</option>
+                        @foreach ($menuItems as $menuItem)
+                            <option value="{{ $menuItem->id }}" {{ old('parent_id') == $menuItem->id ? 'selected' : '' }}>
+                                {{ $menuItem->name }}
+                                @if (($menuItem->children ?? collect())->isNotEmpty())
+                                    (@lang('frontmenu::app.admin.menu.has-children'))
+                                @endif
+                            </option>
+                            @foreach ($menuItem->children ?? collect() as $child)
+                                <option value="{{ $child->id }}" {{ old('parent_id') == $child->id ? 'selected' : '' }}>
+                                    &nbsp;&nbsp;— {{ $child->name }}
+                                </option>
+                            @endforeach
+                        @endforeach
+                    </select>
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="sort_order">
+                        @lang('frontmenu::app.admin.menu.sort-order')
+                    </label>
+                    <input type="number" name="sort_order" id="sort_order" value="{{ old('sort_order', 0) }}" min="0"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                    <small class="mt-1 block text-xs text-gray-500">@lang('frontmenu::app.admin.menu.sort-order-info')</small>
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.status')
+                    </label>
+                    <div class="mt-2 flex items-center gap-6">
+                        <label class="inline-flex items-center gap-2">
+                            <input type="radio" name="status" value="1" {{ old('status', 1) == 1 ? 'checked' : '' }}>
+                            @lang('frontmenu::app.admin.menu.status-active')
+                        </label>
+                        <label class="inline-flex items-center gap-2">
+                            <input type="radio" name="status" value="0" {{ old('status') == 0 ? 'checked' : '' }}>
+                            @lang('frontmenu::app.admin.menu.status-inactive')
+                        </label>
+                    </div>
+                </div>
+            </div>
+
+            <div class="mt-8 flex items-center gap-4">
+                <button type="submit" class="primary-button">
+                    @lang('frontmenu::app.admin.menu.save-btn')
+                </button>
+                <a href="{{ route('admin.frontmenu.index') }}" class="secondary-button">
+                    @lang('frontmenu::app.admin.menu.cancel-btn')
+                </a>
+            </div>
+        </form>
+    </div>
+</x-admin::layouts>

+ 138 - 0
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/edit.blade.php

@@ -0,0 +1,138 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('frontmenu::app.admin.menu.edit-title') - {{ $menuItem->name }}
+    </x-slot>
+
+    <div class="content">
+        <div class="flex items-center justify-between gap-4 max-sm:flex-wrap">
+            <p class="py-[11px] text-xl font-bold text-gray-800 dark:text-white">
+                @lang('frontmenu::app.admin.menu.edit-title')
+            </p>
+        </div>
+
+        @if ($errors->any())
+            <div class="mt-4 rounded bg-red-50 px-4 py-3 text-red-700 dark:bg-red-950 dark:text-red-300">
+                <ul class="list-disc pl-5">
+                    @foreach ($errors->all() as $error)
+                        <li>{{ $error }}</li>
+                    @endforeach
+                </ul>
+            </div>
+        @endif
+
+        <form method="POST" action="{{ route('admin.frontmenu.update', $menuItem->id) }}" class="mt-8">
+            @csrf
+            @method('PUT')
+
+            <div class="grid grid-cols-1 gap-6 md:grid-cols-2">
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="name">
+                        @lang('frontmenu::app.admin.menu.name') <span class="text-red-500">*</span>
+                    </label>
+                    <input type="text" name="name" id="name" value="{{ old('name', $menuItem->name) }}" required
+                           placeholder="@lang('frontmenu::app.admin.menu.name-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="slug">
+                        @lang('frontmenu::app.admin.menu.slug') <span class="text-red-500">*</span>
+                    </label>
+                    <input type="text" name="slug" id="slug" value="{{ old('slug', $menuItem->slug) }}" required
+                           placeholder="@lang('frontmenu::app.admin.menu.slug-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                    <small class="mt-1 block text-xs text-gray-500">@lang('frontmenu::app.admin.menu.slug-info')</small>
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="url">
+                        @lang('frontmenu::app.admin.menu.url')
+                    </label>
+                    <input type="text" name="url" id="url" value="{{ old('url', $menuItem->url) }}"
+                           placeholder="@lang('frontmenu::app.admin.menu.url-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                    <small class="mt-1 block text-xs text-gray-500">@lang('frontmenu::app.admin.menu.url-info')</small>
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="image">
+                        @lang('frontmenu::app.admin.menu.image')
+                    </label>
+                    <input type="text" name="image" id="image" value="{{ old('image', $menuItem->image) }}"
+                           placeholder="@lang('frontmenu::app.admin.menu.image-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="banner">
+                        @lang('frontmenu::app.admin.menu.banner')
+                    </label>
+                    <input type="text" name="banner" id="banner" value="{{ old('banner', $menuItem->banner) }}"
+                           placeholder="@lang('frontmenu::app.admin.menu.banner-placeholder')"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="parent_id">
+                        @lang('frontmenu::app.admin.menu.parent')
+                    </label>
+                    <select name="parent_id" id="parent_id"
+                            class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                        <option value="">@lang('frontmenu::app.admin.menu.parent-none')</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 ?? collect())->isNotEmpty())
+                                        (@lang('frontmenu::app.admin.menu.has-children'))
+                                    @endif
+                                </option>
+                                @foreach ($menuItemOption->children ?? collect() as $child)
+                                    @if ($child->id != $menuItem->id)
+                                        <option value="{{ $child->id }}" {{ old('parent_id', $menuItem->parent_id) == $child->id ? 'selected' : '' }}>
+                                            &nbsp;&nbsp;— {{ $child->name }}
+                                        </option>
+                                    @endif
+                                @endforeach
+                            @endif
+                        @endforeach
+                    </select>
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="sort_order">
+                        @lang('frontmenu::app.admin.menu.sort-order')
+                    </label>
+                    <input type="number" name="sort_order" id="sort_order" value="{{ old('sort_order', $menuItem->sort_order) }}" min="0"
+                           class="mt-1 w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white">
+                    <small class="mt-1 block text-xs text-gray-500">@lang('frontmenu::app.admin.menu.sort-order-info')</small>
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.status')
+                    </label>
+                    <div class="mt-2 flex items-center gap-6">
+                        <label class="inline-flex items-center gap-2">
+                            <input type="radio" name="status" value="1" {{ old('status', $menuItem->status) == 1 ? 'checked' : '' }}>
+                            @lang('frontmenu::app.admin.menu.status-active')
+                        </label>
+                        <label class="inline-flex items-center gap-2">
+                            <input type="radio" name="status" value="0" {{ old('status', $menuItem->status) == 0 ? 'checked' : '' }}>
+                            @lang('frontmenu::app.admin.menu.status-inactive')
+                        </label>
+                    </div>
+                </div>
+            </div>
+
+            <div class="mt-8 flex items-center gap-4">
+                <button type="submit" class="primary-button">
+                    @lang('frontmenu::app.admin.menu.save-btn')
+                </button>
+                <a href="{{ route('admin.frontmenu.index') }}" class="secondary-button">
+                    @lang('frontmenu::app.admin.menu.cancel-btn')
+                </a>
+            </div>
+        </form>
+    </div>
+</x-admin::layouts>

+ 45 - 0
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/index.blade.php

@@ -0,0 +1,45 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('frontmenu::app.admin.menu.title')
+    </x-slot>
+
+    <div class="content">
+        <div class="flex items-center justify-between gap-4 max-sm:flex-wrap">
+            <p class="py-[11px] text-xl font-bold text-gray-800 dark:text-white">
+                @lang('frontmenu::app.admin.menu.title')
+            </p>
+
+            <div class="flex items-center gap-x-2.5">
+                <a href="{{ route('admin.frontmenu.create') }}" class="primary-button">
+                    @lang('frontmenu::app.admin.menu.add-btn')
+                </a>
+            </div>
+        </div>
+
+        @if (session('success'))
+            <div class="mt-4 rounded bg-emerald-50 px-4 py-3 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
+                {{ session('success') }}
+            </div>
+        @endif
+
+        @if (session('error'))
+            <div class="mt-4 rounded bg-red-50 px-4 py-3 text-red-700 dark:bg-red-950 dark:text-red-300">
+                {{ session('error') }}
+            </div>
+        @endif
+
+        <div class="mt-8">
+            @if ($menuItems->isEmpty())
+                <div class="rounded border border-dashed border-gray-300 p-10 text-center text-gray-500 dark:border-gray-700">
+                    @lang('frontmenu::app.admin.menu.add-btn') 后即可在此管理前端导航。
+                </div>
+            @else
+                <div class="space-y-2">
+                    @foreach ($menuItems as $item)
+                        @include('frontmenu::admin.menu.partials.tree', ['item' => $item, 'level' => 0])
+                    @endforeach
+                </div>
+            @endif
+        </div>
+    </div>
+</x-admin::layouts>

+ 54 - 0
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/partials/tree.blade.php

@@ -0,0 +1,54 @@
+@php
+    $children = $item->children ?? collect();
+    $indent = $level * 24;
+@endphp
+
+<div class="rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900" style="margin-left: {{ $indent }}px;">
+    <div class="flex items-center justify-between gap-4 px-4 py-3">
+        <div class="flex items-center gap-3">
+            @if ($item->image)
+                <img src="{{ $item->image }}" alt="{{ $item->name }}" class="h-8 w-8 rounded object-cover">
+            @else
+                <span class="flex h-8 w-8 items-center justify-center rounded bg-gray-100 text-gray-400 dark:bg-gray-800">
+                    <i class="icon-menu"></i>
+                </span>
+            @endif
+
+            <div>
+                <div class="flex items-center gap-2">
+                    <span class="font-semibold text-gray-800 dark:text-white">{{ $item->name }}</span>
+                    <span class="text-xs text-gray-400">({{ $item->slug }})</span>
+                    @if (! $item->status)
+                        <span class="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300">
+                            @lang('frontmenu::app.admin.menu.disabled')
+                        </span>
+                    @endif
+                </div>
+                <div class="text-xs text-gray-500">{{ $item->url }}</div>
+            </div>
+        </div>
+
+        <div class="flex items-center gap-2">
+            <a href="{{ route('admin.frontmenu.edit', $item->id) }}" class="secondary-button text-sm">
+                @lang('frontmenu::app.admin.menu.edit')
+            </a>
+
+            <form action="{{ route('admin.frontmenu.delete', $item->id) }}" method="POST"
+                  onsubmit="return confirm('@lang('frontmenu::app.admin.menu.delete-confirm')');">
+                @csrf
+                @method('DELETE')
+                <button type="submit" class="text-sm text-red-600 hover:text-red-700">
+                    @lang('frontmenu::app.admin.menu.delete')
+                </button>
+            </form>
+        </div>
+    </div>
+</div>
+
+@if ($children->isNotEmpty())
+    <div class="mt-2 space-y-2">
+        @foreach ($children as $child)
+            @include('frontmenu::admin.menu.partials.tree', ['item' => $child, 'level' => $level + 1])
+        @endforeach
+    </div>
+@endif

+ 39 - 0
packages/Longyi/FrontMenu/src/Routes/admin-routes.php

@@ -0,0 +1,39 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\FrontMenu\Http\Controllers\FrontMenuController;
+
+Route::group(['middleware' => ['web', 'admin'], 'prefix' => 'admin'], function () {
+    Route::get('front-menu', [FrontMenuController::class, 'index'])
+        ->name('admin.frontmenu.index')
+        ->defaults('_config', [
+            'view' => 'frontmenu::admin.menu.index',
+        ]);
+
+    Route::get('front-menu/create', [FrontMenuController::class, 'create'])
+        ->name('admin.frontmenu.create')
+        ->defaults('_config', [
+            'view' => 'frontmenu::admin.menu.create',
+        ]);
+
+    Route::post('front-menu', [FrontMenuController::class, 'store'])
+        ->name('admin.frontmenu.store')
+        ->defaults('_config', [
+            'redirect' => 'admin.frontmenu.index',
+        ]);
+
+    Route::get('front-menu/{id}/edit', [FrontMenuController::class, 'edit'])
+        ->name('admin.frontmenu.edit')
+        ->defaults('_config', [
+            'view' => 'frontmenu::admin.menu.edit',
+        ]);
+
+    Route::put('front-menu/{id}', [FrontMenuController::class, 'update'])
+        ->name('admin.frontmenu.update')
+        ->defaults('_config', [
+            'redirect' => 'admin.frontmenu.index',
+        ]);
+
+    Route::delete('front-menu/{id}', [FrontMenuController::class, 'destroy'])
+        ->name('admin.frontmenu.delete');
+});

+ 130 - 0
packages/Webkul/BagistoApi/src/Models/FrontMenuItem.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use Longyi\FrontMenu\Models\FrontMenuItem as BaseFrontMenuItem;
+use Webkul\BagistoApi\Resolver\BaseQueryItemResolver;
+use Webkul\BagistoApi\Resolver\FrontMenuTreeResolver;
+use Webkul\BagistoApi\State\CursorAwareCollectionProvider;
+
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'frontMenu',
+    operations: [
+        new Get(),
+        new GetCollection(
+            provider: CursorAwareCollectionProvider::class,
+            paginationEnabled: true,
+            paginationClientItemsPerPage: true,
+            paginationItemsPerPage: 15,
+            paginationMaximumItemsPerPage: 100,
+        ),
+    ],
+    graphQlOperations: [
+        new Query(resolver: BaseQueryItemResolver::class),
+        new QueryCollection(provider: CursorAwareCollectionProvider::class),
+        new QueryCollection(
+            name: 'tree',
+            paginationEnabled: false,
+            resolver: FrontMenuTreeResolver::class,
+        ),
+    ],
+)]
+class FrontMenuItem extends BaseFrontMenuItem
+{
+    /**
+     * Unique identifier.
+     */
+    #[ApiProperty(identifier: true, writable: false)]
+    public function getId(): int
+    {
+        return (int) $this->id;
+    }
+
+    /**
+     * Menu name.
+     */
+    #[ApiProperty(readable: true, writable: false)]
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    /**
+     * Unique slug.
+     */
+    #[ApiProperty(readable: true, writable: false)]
+    public function getSlug(): ?string
+    {
+        return $this->slug;
+    }
+
+    /**
+     * Menu URL.
+     */
+    #[ApiProperty(readable: true, writable: false)]
+    public function getUrl(): ?string
+    {
+        return $this->url;
+    }
+
+    /**
+     * Menu image.
+     */
+    #[ApiProperty(readable: true, writable: false)]
+    public function getImage(): ?string
+    {
+        return $this->image;
+    }
+
+    /**
+     * Menu banner.
+     */
+    #[ApiProperty(readable: true, writable: false)]
+    public function getBanner(): ?string
+    {
+        return $this->banner;
+    }
+
+    /**
+     * Parent menu id.
+     */
+    #[ApiProperty(readable: true, writable: false)]
+    public function getParentId(): ?int
+    {
+        return $this->parent_id ? (int) $this->parent_id : null;
+    }
+
+    /**
+     * Sort order.
+     */
+    #[ApiProperty(readable: true, writable: false)]
+    public function getSortOrder(): int
+    {
+        return (int) $this->sort_order;
+    }
+
+    /**
+     * Status.
+     */
+    #[ApiProperty(readable: true, writable: false)]
+    public function getStatus(): bool
+    {
+        return (bool) $this->status;
+    }
+
+    /**
+     * Children menu items (hierarchical).
+     */
+    #[ApiProperty(readableLink: true, writable: false, description: 'Child menu items')]
+    public function getChildren()
+    {
+        return $this->children;
+    }
+}

+ 2 - 0
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -31,6 +31,7 @@ use Webkul\BagistoApi\Resolver\ProductCollectionResolver;
 use Webkul\BagistoApi\Resolver\ProductSearchResolver;
 use Webkul\BagistoApi\Resolver\SingleProductBagistoApiResolver;
 use Webkul\BagistoApi\Resolver\PageByUrlKeyResolver;
+use Webkul\BagistoApi\Resolver\FrontMenuTreeResolver;
 use Webkul\BagistoApi\Routing\CustomIriConverter;
 use Webkul\BagistoApi\Serializer\TokenHeaderDenormalizer;
 use Webkul\BagistoApi\Services\CartTokenService;
@@ -564,6 +565,7 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->tag(\Webkul\BagistoApi\Resolver\GdprRequestQueryResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(CustomerQueryResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(PageByUrlKeyResolver::class, QueryCollectionResolverInterface::class);
+        $this->app->tag(FrontMenuTreeResolver::class, QueryCollectionResolverInterface::class);
         $this->app->tag(ProductSearchResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(CategoryProductsResolver::class, QueryItemResolverInterface::class);
 

+ 47 - 0
packages/Webkul/BagistoApi/src/Resolver/FrontMenuTreeResolver.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace Webkul\BagistoApi\Resolver;
+
+use ApiPlatform\GraphQl\Resolver\QueryCollectionResolverInterface;
+use Webkul\BagistoApi\Models\FrontMenuItem;
+
+/**
+ * Resolves the enabled front navigation menu as a hierarchical tree.
+ */
+class FrontMenuTreeResolver implements QueryCollectionResolverInterface
+{
+    public function __invoke(?iterable $collection, array $context): iterable
+    {
+        $items = FrontMenuItem::query()
+            ->where('status', 1)
+            ->orderBy('sort_order')
+            ->orderBy('id')
+            ->get();
+
+        return $this->buildTree($items);
+    }
+
+    /**
+     * Recursively build the menu tree from a flat collection.
+     */
+    protected function buildTree($items, ?int $parentId = null): array
+    {
+        $tree = [];
+
+        foreach ($items as $item) {
+            $itemParentId = $item->parent_id === null ? null : (int) $item->parent_id;
+
+            if ($itemParentId === $parentId) {
+                $children = $this->buildTree($items, (int) $item->id);
+
+                if (! empty($children)) {
+                    $item->setRelation('children', collect($children));
+                }
+
+                $tree[] = $item;
+            }
+        }
+
+        return $tree;
+    }
+}