bianjunhui 3 дней назад
Родитель
Сommit
d520cded74

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

@@ -6,10 +6,22 @@
 
 
 - 后台 Settings 菜单下新增「前端导航菜单」管理入口
 - 后台 Settings 菜单下新增「前端导航菜单」管理入口
 - 菜单项字段:`name`(名称)、`slug`(唯一标识)、`url`(链接)、`image`(图片)、`banner`(横幅)、`status`(状态)、`sort_order`(排序)
 - 菜单项字段:`name`(名称)、`slug`(唯一标识)、`url`(链接)、`image`(图片)、`banner`(横幅)、`status`(状态)、`sort_order`(排序)
+- `image` 与 `banner` 支持图片上传(上传至 S3,自动返回 URL 存储)
 - 支持三级菜单层级(父级/子级/孙级)
 - 支持三级菜单层级(父级/子级/孙级)
+- 支持拖拽排序与拖拽调整层级
 - 级联删除(删除父级时自动删除子级)
 - 级联删除(删除父级时自动删除子级)
 - GraphQL 接口提供单条、集合、树形三种查询
 - 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
 - PHP ^8.1|^8.2

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

@@ -115,6 +115,43 @@ class FrontMenuController extends Controller
         return redirect()->route($this->_config['redirect']);
         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.
      * Normalize request data before persisting.
      */
      */

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

@@ -2,6 +2,7 @@
 
 
 namespace Longyi\FrontMenu\Providers;
 namespace Longyi\FrontMenu\Providers;
 
 
+use Illuminate\Support\Facades\Blade;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\ServiceProvider;
 
 
 class FrontMenuServiceProvider extends ServiceProvider
 class FrontMenuServiceProvider extends ServiceProvider
@@ -29,6 +30,8 @@ class FrontMenuServiceProvider extends ServiceProvider
         $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'frontmenu');
         $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'frontmenu');
         $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
         $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
 
 
+        Blade::anonymousComponentPath(__DIR__ . '/../Resources/views/admin/menu/partials', 'frontmenu');
+
         $this->publishes([
         $this->publishes([
             __DIR__ . '/../Resources/views' => resource_path('views/vendor/longyi/frontmenu'),
             __DIR__ . '/../Resources/views' => resource_path('views/vendor/longyi/frontmenu'),
         ], 'frontmenu-views');
         ], 'frontmenu-views');

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

@@ -8,6 +8,7 @@ return [
             'created' => 'Menu item created successfully',
             'created' => 'Menu item created successfully',
             'updated' => 'Menu item updated successfully',
             'updated' => 'Menu item updated successfully',
             'deleted' => 'Menu item deleted successfully',
             'deleted' => 'Menu item deleted successfully',
+            'reordered' => 'Menu order updated',
             'self-parent' => 'A menu item cannot be its own parent',
             'self-parent' => 'A menu item cannot be its own parent',
 
 
             'add-btn' => 'Add Menu Item',
             'add-btn' => 'Add Menu Item',
@@ -35,9 +36,17 @@ return [
 
 
             'image' => 'Image',
             'image' => 'Image',
             'image-placeholder' => 'Image URL or upload path',
             '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' => 'Banner',
             'banner-placeholder' => 'Banner image URL or upload path',
             'banner-placeholder' => 'Banner image URL or upload path',
+            'banner-upload-hint' => 'Click to upload banner image',
+
+            'drag-hint' => 'Drag to reorder and change hierarchy',
 
 
             'parent' => 'Parent Menu',
             'parent' => 'Parent Menu',
             'parent-none' => 'As top level menu',
             'parent-none' => 'As top level menu',

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

@@ -8,6 +8,7 @@ return [
             'created' => '菜单项创建成功',
             'created' => '菜单项创建成功',
             'updated' => '菜单项更新成功',
             'updated' => '菜单项更新成功',
             'deleted' => '菜单项删除成功',
             'deleted' => '菜单项删除成功',
+            'reordered' => '菜单排序已更新',
             'self-parent' => '不能将菜单项设为自身的子菜单',
             'self-parent' => '不能将菜单项设为自身的子菜单',
 
 
             'add-btn' => '添加菜单项',
             'add-btn' => '添加菜单项',
@@ -35,9 +36,17 @@ return [
 
 
             'image' => '图片',
             'image' => '图片',
             'image-placeholder' => '图片 URL 或上传路径',
             'image-placeholder' => '图片 URL 或上传路径',
+            'image-upload-hint' => '点击上传图片',
+            'image-change' => '更换',
+            'image-remove' => '移除',
+            'image-link-placeholder' => '或直接填写图片链接',
+            'image-uploaded' => '图片上传成功',
 
 
             'banner' => '横幅 Banner',
             'banner' => '横幅 Banner',
             'banner-placeholder' => '横幅图片 URL 或上传路径',
             'banner-placeholder' => '横幅图片 URL 或上传路径',
+            'banner-upload-hint' => '点击上传横幅图片',
+
+            'drag-hint' => '拖动可调整顺序与层级',
 
 
             'parent' => '父级菜单',
             'parent' => '父级菜单',
             'parent-none' => '作为一级菜单',
             'parent-none' => '作为一级菜单',

+ 12 - 8
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/create.blade.php

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

+ 12 - 8
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/edit.blade.php

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

+ 203 - 12
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/index.blade.php

@@ -28,18 +28,209 @@
             </div>
             </div>
         @endif
         @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
+        <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>
     </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>
 </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

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

@@ -36,4 +36,7 @@ Route::group(['middleware' => ['web', 'admin'], 'prefix' => 'admin'], function (
 
 
     Route::delete('front-menu/{id}', [FrontMenuController::class, 'destroy'])
     Route::delete('front-menu/{id}', [FrontMenuController::class, 'destroy'])
         ->name('admin.frontmenu.delete');
         ->name('admin.frontmenu.delete');
+
+    Route::post('front-menu/reorder', [FrontMenuController::class, 'reorder'])
+        ->name('admin.frontmenu.reorder');
 });
 });