Преглед изворни кода

Merge branch 'dev-rewardPoints' into dev

# Conflicts:
#	bootstrap/cache/services.php
bianjunhui пре 14 часа
родитељ
комит
23051033e2
25 измењених фајлова са 1880 додато и 3 уклоњено
  1. 1 0
      bootstrap/providers.php
  2. 1 0
      composer.json
  3. 183 0
      packages/Longyi/FrontMenu/README.md
  4. 30 0
      packages/Longyi/FrontMenu/composer.json
  5. 25 0
      packages/Longyi/FrontMenu/src/Config/acl.php
  6. 11 0
      packages/Longyi/FrontMenu/src/Config/menu.php
  7. 37 0
      packages/Longyi/FrontMenu/src/Console/Commands/InitializeFrontMenu.php
  8. 47 0
      packages/Longyi/FrontMenu/src/Database/Migrations/2026_08_28_000001_create_front_menu_items_table.php
  9. 61 0
      packages/Longyi/FrontMenu/src/Database/Seeders/FrontMenuItemSeeder.php
  10. 183 0
      packages/Longyi/FrontMenu/src/Http/Controllers/FrontMenuController.php
  11. 67 0
      packages/Longyi/FrontMenu/src/Models/FrontMenuItem.php
  12. 49 0
      packages/Longyi/FrontMenu/src/Providers/FrontMenuServiceProvider.php
  13. 75 0
      packages/Longyi/FrontMenu/src/Repositories/FrontMenuItemRepository.php
  14. 71 0
      packages/Longyi/FrontMenu/src/Resources/lang/en/app.php
  15. 71 0
      packages/Longyi/FrontMenu/src/Resources/lang/zh_CN/app.php
  16. 159 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/create.blade.php
  17. 164 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/edit.blade.php
  18. 236 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/index.blade.php
  19. 185 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/partials/image-upload.blade.php
  20. 54 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/partials/tree.blade.php
  21. 42 0
      packages/Longyi/FrontMenu/src/Routes/admin-routes.php
  22. 78 0
      packages/Webkul/BagistoApi/src/Models/FrontMenuItem.php
  23. 2 0
      packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php
  24. 45 0
      packages/Webkul/BagistoApi/src/Resolver/FrontMenuTreeResolver.php
  25. 3 3
      packages/Webkul/Shop/src/Http/Controllers/API/HomeController.php

+ 1 - 0
bootstrap/providers.php

@@ -13,6 +13,7 @@ return [
     Longyi\Blog\Providers\BlogServiceProvider::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

@@ -80,6 +80,7 @@
             "Longyi\\Blog\\": "packages/Longyi/Blog/src/",
             "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/",

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

@@ -0,0 +1,183 @@
+# FrontMenu - 前端三级导航菜单插件
+
+为 Bagisto 提供前台(Shop)三级导航菜单管理,支持在后台 Settings 菜单下进行菜单项的增删改查,并通过 GraphQL 接口对外提供数据查询。
+
+## 功能特性
+
+- 后台 Settings 菜单下新增「前端导航菜单」管理入口
+- 菜单项字段:`name`(名称)、`slug`(唯一标识)、`url`(链接)、`image`(图片)、`banner`(横幅)、`status`(状态)、`sort_order`(排序)
+- `image` 与 `banner` 支持图片上传(上传至 S3,自动返回 URL 存储)
+- 支持三级菜单层级(父级/子级/孙级)
+- 支持拖拽排序与拖拽调整层级
+- 级联删除(删除父级时自动删除子级)
+- GraphQL 接口提供单条、集合、树形三种查询
+
+## 图片上传
+
+- 上传组件复用 `Longyi\ImageUpload` 包的 `ImageUploadService`,通过 `admin.image_upload.upload` 接口上传
+- 图片存储于 `uploads/front-menu` 目录,返回的 URL 写入 `image` / `banner` 字段
+
+## 拖拽排序
+
+- 列表页使用 `vuedraggable` 组件,可拖拽调整菜单顺序与层级
+- 拖拽结束后通过 `admin.frontmenu.reorder` 接口保存新的 `parent_id` 与 `sort_order`
+
+## 依赖
+
+- 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
+php artisan bagisto-api:clear-cache
+
+```
+
+### 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;
+        }
+    }
+}

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

@@ -0,0 +1,47 @@
+<?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->string('wap_image')->nullable();
+            $table->string('wap_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();
+    }
+}

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

@@ -0,0 +1,183 @@
+<?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',
+            'wap_image'  => 'nullable|string|max:2048',
+            'wap_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',
+            'wap_image'  => 'nullable|string|max:2048',
+            'wap_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']);
+    }
+
+    /**
+     * Reorder menu items via drag & drop.
+     */
+    public function reorder(Request $request)
+    {
+        $request->validate([
+            'items' => 'required|array',
+            'items.*.id' => 'required|integer|exists:front_menu_items,id',
+        ]);
+
+        $items = $request->input('items', []);
+
+        // Group by parent so each level gets its own sort order sequence.
+        $grouped = [];
+
+        foreach ($items as $item) {
+            $parentId = $item['parent_id'] ?? null;
+            $grouped[$parentId ?? 'root'][] = $item['id'];
+        }
+
+        foreach ($grouped as $parentKey => $ids) {
+            $parentId = $parentKey === 'root' ? null : $parentKey;
+
+            foreach ($ids as $index => $id) {
+                FrontMenuItem::query()->where('id', $id)->update([
+                    'parent_id'  => $parentId,
+                    'sort_order' => $index,
+                ]);
+            }
+        }
+
+        return response()->json([
+            'success' => true,
+            'message' => __('frontmenu::app.admin.menu.reordered'),
+        ]);
+    }
+
+    /**
+     * Normalize request data before persisting.
+     */
+    protected function prepareData(Request $request): array
+    {
+        $data = $request->only([
+            'name',
+            'slug',
+            'url',
+            'image',
+            'banner',
+            'wap_image',
+            'wap_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;
+    }
+}

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

@@ -0,0 +1,67 @@
+<?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',
+        'wap_image',
+        'wap_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');
+    }
+}

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

@@ -0,0 +1,49 @@
+<?php
+
+namespace Longyi\FrontMenu\Providers;
+
+use Illuminate\Support\Facades\Blade;
+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');
+
+        Blade::anonymousComponentPath(__DIR__ . '/../Resources/views/admin/menu/partials', 'frontmenu');
+
+        $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');
+    }
+}

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

@@ -0,0 +1,71 @@
+<?php
+
+return [
+    'admin' => [
+        'menu' => [
+            'title' => 'Front Navigation Menu',
+
+            'created' => 'Menu item created successfully',
+            'updated' => 'Menu item updated successfully',
+            'deleted' => 'Menu item deleted successfully',
+            'reordered' => 'Menu order updated',
+            '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',
+            'image-upload-hint' => 'Click to upload image',
+            'image-change' => 'Change',
+            'image-remove' => 'Remove',
+            'image-link-placeholder' => 'Or paste an image URL',
+            'image-uploaded' => 'Image uploaded successfully',
+
+            'banner' => 'Banner',
+            'banner-placeholder' => 'Banner image URL or upload path',
+            'banner-upload-hint' => 'Click to upload banner image',
+
+            'wap-image' => 'WAP Image',
+            'wap-image-placeholder' => 'WAP image URL or upload path',
+
+            'wap-banner' => 'WAP Banner',
+            'wap-banner-placeholder' => 'WAP banner image URL or upload path',
+
+            'drag-hint' => 'Drag to reorder and change hierarchy',
+
+            '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',
+        ],
+    ],
+];

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

@@ -0,0 +1,71 @@
+<?php
+
+return [
+    'admin' => [
+        'menu' => [
+            'title' => '前端导航菜单',
+
+            'created' => '菜单项创建成功',
+            'updated' => '菜单项更新成功',
+            'deleted' => '菜单项删除成功',
+            'reordered' => '菜单排序已更新',
+            '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 或上传路径',
+            'image-upload-hint' => '点击上传图片',
+            'image-change' => '更换',
+            'image-remove' => '移除',
+            'image-link-placeholder' => '或直接填写图片链接',
+            'image-uploaded' => '图片上传成功',
+
+            'banner' => '横幅 Banner',
+            'banner-placeholder' => '横幅图片 URL 或上传路径',
+            'banner-upload-hint' => '点击上传横幅图片',
+
+            'wap-image' => 'WAP 图片',
+            'wap-image-placeholder' => 'WAP 图片 URL 或上传路径',
+
+            'wap-banner' => 'WAP 横幅',
+            'wap-banner-placeholder' => 'WAP 横幅图片 URL 或上传路径',
+
+            'drag-hint' => '拖动可调整顺序与层级',
+
+            'parent' => '父级菜单',
+            'parent-none' => '作为一级菜单',
+            'has-children' => '有子菜单',
+
+            'sort-order' => '排序',
+            'sort-order-info' => '数字越小越靠前',
+
+            'status' => '状态',
+            'status-active' => '启用',
+            'status-inactive' => '禁用',
+
+            'disabled' => '已禁用',
+        ],
+    ],
+];

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

@@ -0,0 +1,159 @@
+<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">
+                        @lang('frontmenu::app.admin.menu.image')
+                    </label>
+                    <x-frontmenu::image-upload
+                        name="image"
+                        :value="old('image')"
+                        label="@lang('frontmenu::app.admin.menu.image')"
+                    />
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.banner')
+                    </label>
+                    <x-frontmenu::image-upload
+                        name="banner"
+                        :value="old('banner')"
+                        label="@lang('frontmenu::app.admin.menu.banner')"
+                    />
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.wap-image')
+                    </label>
+                    <x-frontmenu::image-upload
+                        name="wap_image"
+                        :value="old('wap_image')"
+                        label="@lang('frontmenu::app.admin.menu.wap-image')"
+                    />
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.wap-banner')
+                    </label>
+                    <x-frontmenu::image-upload
+                        name="wap_banner"
+                        :value="old('wap_banner')"
+                        label="@lang('frontmenu::app.admin.menu.wap-banner')"
+                    />
+                </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>

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

@@ -0,0 +1,164 @@
+<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">
+                        @lang('frontmenu::app.admin.menu.image')
+                    </label>
+                    <x-frontmenu::image-upload
+                        name="image"
+                        :value="old('image', $menuItem->image)"
+                        label="@lang('frontmenu::app.admin.menu.image')"
+                    />
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.banner')
+                    </label>
+                    <x-frontmenu::image-upload
+                        name="banner"
+                        :value="old('banner', $menuItem->banner)"
+                        label="@lang('frontmenu::app.admin.menu.banner')"
+                    />
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.wap-image')
+                    </label>
+                    <x-frontmenu::image-upload
+                        name="wap_image"
+                        :value="old('wap_image', $menuItem->wap_image)"
+                        label="@lang('frontmenu::app.admin.menu.wap-image')"
+                    />
+                </div>
+
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.wap-banner')
+                    </label>
+                    <x-frontmenu::image-upload
+                        name="wap_banner"
+                        :value="old('wap_banner', $menuItem->wap_banner)"
+                        label="@lang('frontmenu::app.admin.menu.wap-banner')"
+                    />
+                </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>

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

@@ -0,0 +1,236 @@
+<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
+
+        <p class="mt-4 text-xs text-gray-500">
+            <i class="icon-move"></i>
+            @lang('frontmenu::app.admin.menu.drag-hint')
+        </p>
+
+        <div class="mt-4">
+            <v-front-menu-tree :items='@json($menuItems)'></v-front-menu-tree>
+        </div>
+    </div>
+
+    @pushOnce('scripts')
+        <script type="text/x-template" id="v-front-menu-tree-template">
+            <div>
+                <draggable
+                    :list="items"
+                    group="front-menu"
+                    item-key="id"
+                    ghost-class="draggable-ghost"
+                    animation="200"
+                    @end="saveOrder"
+                >
+                    <template #item="{ element }">
+                        <div class="rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900">
+                            <div class="flex items-center justify-between gap-4 px-4 py-3">
+                                <div class="flex items-center gap-3">
+                                    <span class="icon-move cursor-move text-gray-400"></span>
+
+                                    <template v-if="element.image">
+                                        <img :src="element.image" :alt="element.name" class="h-8 w-8 rounded object-cover">
+                                    </template>
+                                    <template v-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>
+                                    </template>
+
+                                    <div>
+                                        <div class="flex items-center gap-2">
+                                            <span class="font-semibold text-gray-800 dark:text-white">@{{ element.name }}</span>
+                                            <span class="text-xs text-gray-400">(@{{ element.slug }})</span>
+                                            <span v-if="! element.status" 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>
+                                        </div>
+                                        <div class="text-xs text-gray-500">@{{ element.url }}</div>
+                                    </div>
+                                </div>
+
+                                <div class="flex items-center gap-2">
+                                    <a :href="'{{ route('admin.frontmenu.index') }}/' + element.id + '/edit'" class="secondary-button !px-3 !py-1.5 text-sm">
+                                        @lang('frontmenu::app.admin.menu.edit')
+                                    </a>
+
+                                    <form :action="'{{ route('admin.frontmenu.index') }}/' + element.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>
+
+                            <v-front-menu-tree-children
+                                :items="element.children"
+                                @change="$emit('reorder', $event)"
+                            ></v-front-menu-tree-children>
+                        </div>
+                    </template>
+                </draggable>
+            </div>
+        </script>
+
+        <script type="text/x-template" id="v-front-menu-tree-children-template">
+            <div class="px-4 pb-3" v-if="items && items.length">
+                <draggable
+                    :list="items"
+                    group="front-menu"
+                    item-key="id"
+                    ghost-class="draggable-ghost"
+                    animation="200"
+                    @end="$emit('change')"
+                >
+                    <template #item="{ element }">
+                        <div class="rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900">
+                            <div class="flex items-center justify-between gap-4 px-4 py-3">
+                                <div class="flex items-center gap-3">
+                                    <span class="icon-move cursor-move text-gray-400"></span>
+
+                                    <template v-if="element.image">
+                                        <img :src="element.image" :alt="element.name" class="h-8 w-8 rounded object-cover">
+                                    </template>
+                                    <template v-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>
+                                    </template>
+
+                                    <div>
+                                        <div class="flex items-center gap-2">
+                                            <span class="font-semibold text-gray-800 dark:text-white">@{{ element.name }}</span>
+                                            <span class="text-xs text-gray-400">(@{{ element.slug }})</span>
+                                            <span v-if="! element.status" 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>
+                                        </div>
+                                        <div class="text-xs text-gray-500">@{{ element.url }}</div>
+                                    </div>
+                                </div>
+
+                                <div class="flex items-center gap-2">
+                                    <a :href="'{{ route('admin.frontmenu.index') }}/' + element.id + '/edit'" class="secondary-button !px-3 !py-1.5 text-sm">
+                                        @lang('frontmenu::app.admin.menu.edit')
+                                    </a>
+
+                                    <form :action="'{{ route('admin.frontmenu.index') }}/' + element.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>
+
+                            <v-front-menu-tree-children
+                                :items="element.children"
+                                @change="$emit('change')"
+                            ></v-front-menu-tree-children>
+                        </div>
+                    </template>
+                </draggable>
+            </div>
+        </script>
+
+        <script type="module">
+            const childComponent = {
+                template: '#v-front-menu-tree-children-template',
+
+                props: {
+                    items: {
+                        type: Array,
+                        default: () => [],
+                    },
+                },
+
+                emits: ['change'],
+            };
+
+            app.component('v-front-menu-tree-children', childComponent);
+
+            app.component('v-front-menu-tree', {
+                template: '#v-front-menu-tree-template',
+
+                props: {
+                    items: {
+                        type: Array,
+                        default: () => [],
+                    },
+                },
+
+                methods: {
+                    saveOrder() {
+                        const payload = this.flatten(this.items, null);
+
+                        this.$axios
+                            .post('{{ route('admin.frontmenu.reorder') }}', { items: payload })
+                            .then((response) => {
+                                this.$emitter.emit('add-flash', {
+                                    type: 'success',
+                                    message: response.data.message,
+                                });
+                            })
+                            .catch((error) => {
+                                this.$emitter.emit('add-flash', {
+                                    type: 'error',
+                                    message: error.response?.data?.message || '{{ trans('frontmenu::app.admin.menu.reordered') }}',
+                                });
+
+                                window.location.reload();
+                            });
+                    },
+
+                    flatten(nodes, parentId) {
+                        let result = [];
+
+                        nodes.forEach((node, index) => {
+                            result.push({
+                                id: node.id,
+                                parent_id: parentId,
+                            });
+
+                            if (node.children && node.children.length) {
+                                result = result.concat(this.flatten(node.children, node.id));
+                            }
+                        });
+
+                        return result;
+                    },
+                },
+            });
+        </script>
+    @endPushOnce
+</x-admin::layouts>

+ 185 - 0
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/partials/image-upload.blade.php

@@ -0,0 +1,185 @@
+{{--
+    单图上传组件(图片/横幅)
+    用法:<x-frontmenu::image-upload name="image" label="图片" :value="old('image')" />
+    支持点击上传,也可直接填写图片链接。上传/填写后写入同名隐藏字段,随表单提交。
+--}}
+@props([
+    'name' => 'image',
+    'label' => '',
+    'value' => '',
+])
+
+<front-menu-image-upload
+    name="{{ $name }}"
+    :value='@json($value)'
+    label="{{ $label }}"
+></front-menu-image-upload>
+
+@pushOnce('scripts')
+    <script type="text/x-template" id="front-menu-image-upload-template">
+        <div>
+            <input type="hidden" :name="name" :value="modelValue" />
+
+            <div class="flex items-start gap-3">
+                <div
+                    class="relative flex h-[100px] w-[100px] cursor-pointer items-center justify-center overflow-hidden rounded border border-dashed border-gray-300 bg-gray-50 transition-all hover:border-blue-400 dark:border-gray-700 dark:bg-gray-800"
+                    @click="triggerUpload"
+                >
+                    <template v-if="modelValue">
+                        <img :src="modelValue" class="h-full w-full object-cover" />
+                        <div class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-all hover:opacity-100">
+                            <span class="text-xs font-semibold text-white">
+                                @lang('frontmenu::app.admin.menu.image-change')
+                            </span>
+                        </div>
+                    </template>
+
+                    <template v-else>
+                        <div class="flex flex-col items-center gap-1 text-gray-400">
+                            <span class="icon-image text-2xl"></span>
+                            <span class="px-2 text-center text-xs">@{{ uploadHint }}</span>
+                        </div>
+                    </template>
+                </div>
+
+                <input
+                    type="file"
+                    accept="image/*"
+                    class="hidden"
+                    ref="fileInput"
+                    @change="handleFile"
+                />
+
+                <div class="flex flex-1 flex-col gap-2">
+                    <button
+                        type="button"
+                        class="secondary-button !px-3 !py-1.5 text-xs self-start"
+                        @click="triggerUpload"
+                    >
+                        <span v-if="modelValue">@lang('frontmenu::app.admin.menu.image-change')</span>
+                        <span v-else>@lang('frontmenu::app.admin.menu.image-upload-hint')</span>
+                    </button>
+
+                    <div class="flex items-center gap-2">
+                        <input
+                            type="text"
+                            class="w-full rounded border border-gray-300 px-3 py-2 text-sm text-gray-600 transition-all hover:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"
+                            :placeholder="placeholder"
+                            :value="modelValue"
+                            @input="onInput"
+                        />
+
+                        <button
+                            type="button"
+                            v-if="modelValue"
+                            class="text-xs text-red-600 hover:underline whitespace-nowrap"
+                            @click="remove"
+                        >
+                            @lang('frontmenu::app.admin.menu.image-remove')
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </script>
+
+    <script type="module">
+        app.component('front-menu-image-upload', {
+            template: '#front-menu-image-upload-template',
+
+            props: {
+                name: {
+                    type: String,
+                    default: 'image',
+                },
+
+                value: {
+                    type: String,
+                    default: '',
+                },
+
+                label: {
+                    type: String,
+                    default: '',
+                },
+            },
+
+            data() {
+                return {
+                    modelValue: this.value || '',
+                };
+            },
+
+            computed: {
+                uploadHint() {
+                    return this.label || '{{ trans('frontmenu::app.admin.menu.image-upload-hint') }}';
+                },
+
+                placeholder() {
+                    return '{{ trans('frontmenu::app.admin.menu.image-link-placeholder') }}';
+                },
+            },
+
+            methods: {
+                triggerUpload() {
+                    this.$refs.fileInput.click();
+                },
+
+                onInput(event) {
+                    this.modelValue = event.target.value;
+                },
+
+                handleFile(event) {
+                    const file = event.target.files[0];
+
+                    if (! file) {
+                        return;
+                    }
+
+                    if (! file.type.includes('image/')) {
+                        this.$emitter.emit('add-flash', {
+                            type: 'warning',
+                            message: '{{ trans('frontmenu::app.admin.menu.image-upload-hint') }}',
+                        });
+
+                        event.target.value = '';
+
+                        return;
+                    }
+
+                    const formData = new FormData();
+                    formData.append('image', file);
+                    formData.append('directory', 'uploads/front-menu');
+
+                    this.$axios
+                        .post('{{ route('admin.image_upload.upload') }}', formData)
+                        .then((response) => {
+                            const url = response.data?.data?.url;
+
+                            if (url) {
+                                this.modelValue = url;
+
+                                this.$emitter.emit('add-flash', {
+                                    type: 'success',
+                                    message: '{{ trans('frontmenu::app.admin.menu.image-uploaded') }}',
+                                });
+                            }
+                        })
+                        .catch((error) => {
+                            this.$emitter.emit('add-flash', {
+                                type: 'error',
+                                message: error.response?.data?.message || '{{ trans('frontmenu::app.admin.menu.image-upload-hint') }}',
+                            });
+                        })
+                        .finally(() => {
+                            event.target.value = '';
+                        });
+                },
+
+                remove() {
+                    this.modelValue = '';
+                },
+            },
+        });
+    </script>
+@endPushOnce

+ 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

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

@@ -0,0 +1,42 @@
+<?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');
+
+    Route::post('front-menu/reorder', [FrontMenuController::class, 'reorder'])
+        ->name('admin.frontmenu.reorder');
+});

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

@@ -0,0 +1,78 @@
+<?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;
+    }
+
+    /**
+     * Override the parent relationship to target the API resource model,
+     * so API Platform can resolve the self-referencing relation.
+     */
+    public function parent()
+    {
+        return $this->belongsTo(self::class, 'parent_id');
+    }
+
+    /**
+     * Override the children relationship to target the API resource model,
+     * so API Platform can resolve the self-referencing relation.
+     */
+    public function children()
+    {
+        return $this->hasMany(self::class, 'parent_id')
+            ->orderBy('sort_order')
+            ->orderBy('id');
+    }
+
+    /**
+     * 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);
 

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

@@ -0,0 +1,45 @@
+<?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);
+
+                $item->setRelation('children', collect($children));
+
+                $tree[] = $item;
+            }
+        }
+
+        return $tree;
+    }
+}

+ 3 - 3
packages/Webkul/Shop/src/Http/Controllers/API/HomeController.php

@@ -66,16 +66,16 @@ class HomeController extends APIController
             }
 
             // 2. 获取分类树
-            $categories = Cache::remember(
+            /*$categories = Cache::remember(
                 'home_categories:' . $channel->code . ':' . $locale,
                 self::CACHE_TTL,
                 fn () => $this->categoryRepository->getVisibleCategoryTree($channel->root_category_id)
-            );
+            );*/
 
             return [
                 'channel'    => $channel->code,
                 'sections'   => $sections,
-                'categories' => $this->formatCategoryTree($categories),
+                //'categories' => $this->formatCategoryTree($categories),
             ];
         });