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

Merge branch 'dev-rewardPoints' into dev

bianjunhui 1 неделя назад
Родитель
Сommit
de1e292bb8

+ 1 - 1
packages/Longyi/ImageUpload/src/Config/image_upload.php

@@ -47,7 +47,7 @@ return [
     | The directory where images will be stored.
     |
     */
-    'upload_directory' => 'images/uploads',
+    'upload_directory' => 'uploads',
 
     /*
     |--------------------------------------------------------------------------

+ 30 - 0
packages/Longyi/ImageUpload/src/Database/Migrations/2026_07_14_000001_create_image_folders_table.php

@@ -0,0 +1,30 @@
+<?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('image_folders', function (Blueprint $table) {
+            $table->id();
+            $table->string('name', 255)->comment('文件夹名称');
+            $table->unsignedBigInteger('parent_id')->default(0)->index()->comment('父文件夹ID,0为根目录');
+            $table->string('path', 500)->comment('完整路径,用/分隔');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('image_folders');
+    }
+};

+ 35 - 0
packages/Longyi/ImageUpload/src/Database/Migrations/2026_07_14_000002_create_image_uploads_table.php

@@ -0,0 +1,35 @@
+<?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('image_uploads', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('folder_id')->default(0)->index()->comment('所属文件夹ID,0为根目录');
+            $table->string('original_name', 255)->comment('原始文件名');
+            $table->string('file_name', 255)->comment('存储文件名');
+            $table->string('file_path', 500)->comment('S3存储路径');
+            $table->string('file_url', 1000)->comment('完整访问URL');
+            $table->string('mime_type', 100)->comment('文件MIME类型');
+            $table->unsignedBigInteger('file_size')->default(0)->comment('文件大小(字节)');
+            $table->string('disk', 50)->default('s3')->comment('存储磁盘');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('image_uploads');
+    }
+};

+ 205 - 13
packages/Longyi/ImageUpload/src/Http/Controllers/Admin/ImageUploadController.php

@@ -4,6 +4,8 @@ namespace Longyi\ImageUpload\Http\Controllers\Admin;
 
 use App\Http\Controllers\Controller;
 use Illuminate\Http\Request;
+use Longyi\ImageUpload\Models\ImageFolder;
+use Longyi\ImageUpload\Models\ImageUpload as ImageUploadModel;
 use Longyi\ImageUpload\Services\ImageUploadService;
 use Exception;
 
@@ -72,10 +74,33 @@ class ImageUploadController extends Controller
             ]);
 
             $files = $request->file('images');
-            $directory = $request->input('directory');
+            $directory = 'uploads/'.$request->input('directory');
 
             $results = $this->imageUploadService->uploadMultiple($files, $directory);
 
+            // 解析 folder_id:如果传了 directory,按目录名查找文件夹
+            $folderId = 0;
+            if (! empty($directory)) {
+                $folder = ImageFolder::where('path', $directory)->first();
+                $folderId = $folder ? $folder->id : 0;
+            }
+
+            // 将上传成功的图片记录写入 image_uploads 表
+            foreach ($results as $result) {
+                if (! empty($result['success'])) {
+                    ImageUploadModel::create([
+                        'folder_id'      => $folderId,
+                        'original_name'  => $result['original_name'] ?? '',
+                        'file_name'      => basename($result['path'] ?? ''),
+                        'file_path'      => $result['path'] ?? '',
+                        'file_url'       => $result['url'] ?? '',
+                        'mime_type'      => $result['mime_type'] ?? '',
+                        'file_size'      => $result['size'] ?? 0,
+                        'disk'           => $result['disk'] ?? 's3',
+                    ]);
+                }
+            }
+
             $successCount = count(array_filter($results, fn($r) => $r['success'] ?? false));
             $failedCount = count($results) - $successCount;
 
@@ -96,7 +121,7 @@ class ImageUploadController extends Controller
     }
 
     /**
-     * Delete an image.
+     * Delete an image (S3 + database record).
      *
      * @param Request $request
      * @return \Illuminate\Http\JsonResponse
@@ -112,19 +137,16 @@ class ImageUploadController extends Controller
             $path = $request->input('path');
             $disk = $request->input('disk');
 
+            // 删除 S3 文件
             $deleted = $this->imageUploadService->delete($path, $disk);
 
-            if ($deleted) {
-                return response()->json([
-                    'success' => true,
-                    'message' => __('Image deleted successfully'),
-                ]);
-            } else {
-                return response()->json([
-                    'success' => false,
-                    'message' => __('Failed to delete image'),
-                ], 422);
-            }
+            // 同时删除数据库记录
+            ImageUploadModel::where('file_path', $path)->delete();
+
+            return response()->json([
+                'success' => true,
+                'message' => __('Image deleted successfully'),
+            ]);
         } catch (Exception $e) {
             return response()->json([
                 'success' => false,
@@ -163,4 +185,174 @@ class ImageUploadController extends Controller
             ], 422);
         }
     }
+
+    /**
+     * Get folders list (from database).
+     *
+     * @param Request $request
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function getFolders(Request $request)
+    {
+        try {
+            $folders = ImageFolder::orderBy('created_at', 'desc')->get()->map(function ($folder) {
+                return [
+                    'id'   => $folder->id,
+                    'name' => $folder->name,
+                    'path' => $folder->path,
+                ];
+            });
+
+            return response()->json([
+                'success' => true,
+                'data'    => [
+                    'folders' => $folders,
+                ],
+            ]);
+        } catch (Exception $e) {
+            return response()->json([
+                'success' => false,
+                'message' => $e->getMessage(),
+            ], 422);
+        }
+    }
+
+    /**
+     * Create a folder (database only, no S3).
+     *
+     * @param Request $request
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function createFolder(Request $request)
+    {
+        try {
+            $request->validate([
+                'folder_name'   => 'required|string|max:255',
+                'parent_folder' => 'nullable|string',
+            ]);
+
+            $folderName   = trim($request->input('folder_name'));
+            $parentFolder = trim($request->input('parent_folder', ''));
+
+            // 检查同名文件夹
+            $exists = ImageFolder::where('name', $folderName)
+                ->where('parent_id', 0)
+                ->exists();
+
+            if ($exists) {
+                return response()->json([
+                    'success' => false,
+                    'message' => __('Folder already exists'),
+                ], 422);
+            }
+
+            $basePath = config('image_upload.upload_directory', 'uploads');
+            $folder = ImageFolder::create([
+                'name'      => $folderName,
+                'parent_id' => 0,
+                'path'      => $basePath . '/' . $folderName,
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'message' => __('Folder created successfully'),
+                'data'    => [
+                    'id'   => $folder->id,
+                    'name' => $folder->name,
+                    'path' => $folder->path,
+                ],
+            ]);
+        } catch (Exception $e) {
+            return response()->json([
+                'success' => false,
+                'message' => $e->getMessage(),
+            ], 422);
+        }
+    }
+
+    /**
+     * Delete a folder (database + related images on S3).
+     *
+     * @param Request $request
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function deleteFolder(Request $request)
+    {
+        try {
+            $request->validate([
+                'folder_id' => 'required|integer',
+            ]);
+
+            $folderId = $request->input('folder_id');
+            $folder = ImageFolder::findOrFail($folderId);
+
+            // 删除该文件夹下所有图片的 S3 文件和数据库记录
+            $images = ImageUploadModel::where('folder_id', $folderId)->get();
+            foreach ($images as $image) {
+                // 从 S3 删除图片文件
+                try {
+                    $this->imageUploadService->delete($image->file_path, $image->disk);
+                } catch (\Exception $e) {
+                    // S3 删除失败不影响数据库删除
+                }
+                $image->delete();
+            }
+
+            // 删除文件夹记录
+            $folder->delete();
+
+            return response()->json([
+                'success' => true,
+                'message' => __('Folder deleted successfully'),
+            ]);
+        } catch (Exception $e) {
+            return response()->json([
+                'success' => false,
+                'message' => $e->getMessage(),
+            ], 422);
+        }
+    }
+
+    /**
+     * Get images in a folder (from database).
+     *
+     * @param Request $request
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function getImages(Request $request)
+    {
+        try {
+            $folderId = $request->input('folder_id', 0);
+
+            $query = ImageUploadModel::orderBy('created_at', 'desc');
+
+            if ($folderId > 0) {
+                $query->where('folder_id', $folderId);
+            } else {
+                $query->where('folder_id', 0);
+            }
+
+            $images = $query->get()->map(function ($image) {
+                return [
+                    'id'            => $image->id,
+                    'path'          => $image->file_path,
+                    'name'          => $image->original_name,
+                    'url'           => $image->file_url,
+                    'size'          => $image->file_size,
+                    'mime_type'     => $image->mime_type,
+                    'last_modified' => $image->updated_at->timestamp,
+                ];
+            });
+
+            return response()->json([
+                'success' => true,
+                'data'    => $images,
+            ]);
+        } catch (Exception $e) {
+            return response()->json([
+                'success' => false,
+                'message' => $e->getMessage(),
+            ], 422);
+        }
+    }
 }

+ 50 - 0
packages/Longyi/ImageUpload/src/Models/ImageFolder.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace Longyi\ImageUpload\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class ImageFolder extends Model
+{
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = 'image_folders';
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'name',
+        'parent_id',
+        'path',
+    ];
+
+    /**
+     * Get the images in this folder.
+     */
+    public function images()
+    {
+        return $this->hasMany(ImageUpload::class, 'folder_id');
+    }
+
+    /**
+     * Get the parent folder.
+     */
+    public function parent()
+    {
+        return $this->belongsTo(ImageFolder::class, 'parent_id');
+    }
+
+    /**
+     * Get the child folders.
+     */
+    public function children()
+    {
+        return $this->hasMany(ImageFolder::class, 'parent_id');
+    }
+}

+ 39 - 0
packages/Longyi/ImageUpload/src/Models/ImageUpload.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Longyi\ImageUpload\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class ImageUpload extends Model
+{
+    /**
+     * The table associated with the model.
+     *
+     * @var string
+     */
+    protected $table = 'image_uploads';
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'folder_id',
+        'original_name',
+        'file_name',
+        'file_path',
+        'file_url',
+        'mime_type',
+        'file_size',
+        'disk',
+    ];
+
+    /**
+     * Get the folder that owns this image.
+     */
+    public function folder()
+    {
+        return $this->belongsTo(ImageFolder::class, 'folder_id');
+    }
+}

+ 8 - 0
packages/Longyi/ImageUpload/src/Providers/ImageUploadServiceProvider.php

@@ -19,6 +19,9 @@ class ImageUploadServiceProvider extends ServiceProvider
         $this->loadRoutesFrom(__DIR__ . '/../Routes/admin-routes.php');
         $this->loadRoutesFrom(__DIR__ . '/../Routes/shop-routes.php');
 
+        // Load database migrations
+        $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
+
         // Load views
         $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'imageupload');
 
@@ -39,6 +42,11 @@ class ImageUploadServiceProvider extends ServiceProvider
         $this->publishes([
             __DIR__ . '/../Resources/lang' => resource_path('lang/vendor/imageupload'),
         ], 'translations');
+
+        // Publish migrations
+        $this->publishes([
+            __DIR__ . '/../Database/Migrations' => database_path('migrations'),
+        ], 'migrations');
     }
 
     /**

+ 26 - 0
packages/Longyi/ImageUpload/src/Resources/lang/en/app.php

@@ -21,5 +21,31 @@ return [
             'delete_success' => 'Image deleted successfully',
             'delete_failed' => 'Failed to delete image',
         ],
+        'media-manager' => [
+            'title' => 'Media Manager',
+            'upload_images' => 'Upload Images',
+            'create_folder' => 'Create Folder',
+            'folder_name' => 'Folder Name',
+            'folder_name_placeholder' => 'Enter folder name',
+            'create' => 'Create',
+            'cancel' => 'Cancel',
+            'all_folders' => 'All Folders',
+            'no_folders' => 'No folders yet',
+            'no_images' => 'No images yet',
+            'select' => 'Select',
+            'selected' => 'Selected',
+            'insert' => 'Insert Image',
+            'confirm_delete_folder' => 'Are you sure you want to delete this folder? All images in it will also be deleted.',
+            'confirm_delete_image' => 'Are you sure you want to delete this image?',
+            'folder_created' => 'Folder created successfully',
+            'folder_deleted' => 'Folder deleted successfully',
+            'image_deleted' => 'Image deleted successfully',
+            'upload_success' => 'Upload successful',
+            'drag_or_click' => 'Drag & drop files here or click to upload',
+            'uploading' => 'Uploading...',
+            'loading' => 'Loading...',
+            'root_directory' => 'Root Directory',
+            'back' => 'Back',
+        ],
     ],
 ];

+ 26 - 0
packages/Longyi/ImageUpload/src/Resources/lang/zh_CN/app.php

@@ -21,5 +21,31 @@ return [
             'delete_success' => '图片删除成功',
             'delete_failed' => '图片删除失败',
         ],
+        'media-manager' => [
+            'title' => '媒体管理器',
+            'upload_images' => '上传图片',
+            'create_folder' => '新建文件夹',
+            'folder_name' => '文件夹名称',
+            'folder_name_placeholder' => '请输入文件夹名称',
+            'create' => '创建',
+            'cancel' => '取消',
+            'all_folders' => '全部文件夹',
+            'no_folders' => '暂无文件夹',
+            'no_images' => '暂无图片',
+            'select' => '选择',
+            'selected' => '已选择',
+            'insert' => '插入图片',
+            'confirm_delete_folder' => '确定要删除此文件夹吗?文件夹中的所有图片也将被删除。',
+            'confirm_delete_image' => '确定要删除此图片吗?',
+            'folder_created' => '文件夹创建成功',
+            'folder_deleted' => '文件夹删除成功',
+            'image_deleted' => '图片删除成功',
+            'upload_success' => '上传成功',
+            'drag_or_click' => '拖拽文件到此处或点击上传',
+            'uploading' => '上传中...',
+            'loading' => '加载中...',
+            'root_directory' => '根目录',
+            'back' => '返回',
+        ],
     ],
 ];

+ 510 - 0
packages/Longyi/ImageUpload/src/Resources/views/components/media-manager.blade.php

@@ -0,0 +1,510 @@
+<!-- Media Manager Component for TinyMCE -->
+<script type="text/x-template" id="v-media-manager-template">
+    <div>
+        <!-- Media Manager Modal -->
+        <x-admin::modal ref="mediaManagerModal">
+            <!-- Modal Header -->
+            <x-slot:header>
+                <p class="flex items-center gap-2.5 text-lg font-bold text-gray-800 dark:text-white">
+                    <span class="icon-image text-2xl text-gray-800"></span>
+                    @lang('imageupload::app.admin.media-manager.title')
+                </p>
+            </x-slot>
+
+            <!-- Modal Content -->
+            <x-slot:content>
+                <div class="flex gap-4 h-[500px]">
+                    <!-- Left Sidebar: Folder Tree -->
+                    <div class="w-56 border-r border-gray-200 dark:border-gray-600 pr-4 flex flex-col">
+                        <!-- Create Folder Button -->
+                        <div class="mb-3">
+                            <button
+                                type="button"
+                                class="primary-button w-full text-sm py-1.5"
+                                @click="showCreateFolder = true"
+                            >
+                                <span class="icon-folder text-sm"></span>
+                                @lang('imageupload::app.admin.media-manager.create_folder')
+                            </button>
+                        </div>
+
+                        <!-- Create Folder Input -->
+                        <div v-if="showCreateFolder" class="mb-3">
+                            <div class="flex gap-1">
+                                <input
+                                    type="text"
+                                    v-model="newFolderName"
+                                    placeholder="@lang('imageupload::app.admin.media-manager.folder_name_placeholder')"
+                                    class="flex-1 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm dark:bg-gray-700 dark:text-white"
+                                    @keyup.enter="createFolder"
+                                />
+                                <button
+                                    type="button"
+                                    class="primary-button text-xs px-2 py-1"
+                                    @click="createFolder"
+                                    :disabled="!newFolderName.trim()"
+                                >
+                                    @lang('imageupload::app.admin.media-manager.create')
+                                </button>
+                            </div>
+                            <button
+                                type="button"
+                                class="text-xs text-gray-500 hover:text-gray-700 mt-1"
+                                @click="showCreateFolder = false; newFolderName = ''"
+                            >
+                                @lang('imageupload::app.admin.media-manager.cancel')
+                            </button>
+                        </div>
+
+                        <!-- Folder List -->
+                        <div class="flex-1 overflow-y-auto">
+                            <p class="text-xs font-semibold text-gray-500 uppercase mb-2">
+                                @lang('imageupload::app.admin.media-manager.all_folders')
+                            </p>
+
+                            <!-- Sub Folders -->
+                            <div
+                                v-for="folder in folders"
+                                :key="folder.id"
+                                class="flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-sm mb-0.5"
+                                :class="currentFolderId === folder.id ? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200' : 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'"
+                                @click="selectFolder(folder.id)"
+                            >
+                                <span class="flex items-center gap-1.5 truncate flex-1">
+                                    <span class="icon-folder text-sm"></span>
+                                    <span class="truncate">@{{ folder.name }}</span>
+                                </span>
+                                <button
+                                    type="button"
+                                    class="text-red-500 hover:text-red-700 ml-1 text-xs px-1 py-0.5 rounded hover:bg-red-50"
+                                    @click.stop="confirmDeleteFolder(folder)"
+                                    title="@lang('imageupload::app.admin.media-manager.delete')"
+                                >
+                                    🗑
+                                </button>
+                            </div>
+
+                            <p v-if="folders.length === 0" class="text-xs text-gray-400 py-2">
+                                @lang('imageupload::app.admin.media-manager.no_folders')
+                            </p>
+                        </div>
+                    </div>
+
+                    <!-- Right Side: Image Browser & Upload -->
+                    <div class="flex-1 flex flex-col">
+                        <!-- Upload Area -->
+                        <div
+                            class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 mb-4 text-center cursor-pointer transition-colors hover:border-blue-400"
+                            :class="{ 'border-blue-400 bg-blue-50 dark:bg-blue-900/20': isDragOver }"
+                            @dragover.prevent="isDragOver = true"
+                            @dragleave.prevent="isDragOver = false"
+                            @drop.prevent="handleDrop"
+                            @click="$refs.fileInput.click()"
+                        >
+                            <input
+                                ref="fileInput"
+                                type="file"
+                                accept="image/*"
+                                multiple
+                                class="hidden"
+                                @change="handleFileSelect"
+                            />
+
+                            <div v-if="uploading">
+                                <div class="flex items-center justify-center gap-2 text-blue-600">
+                                    <span class="animate-spin inline-block w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full"></span>
+                                    @lang('imageupload::app.admin.media-manager.uploading')
+                                </div>
+                            </div>
+                            <div v-else>
+                                <span class="icon-upload text-3xl text-gray-400 block mb-2"></span>
+                                <p class="text-sm text-gray-500 dark:text-gray-400">
+                                    @lang('imageupload::app.admin.media-manager.drag_or_click')
+                                </p>
+                            </div>
+                        </div>
+
+                        <!-- Image Grid -->
+                        <div class="flex-1 overflow-y-auto">
+                            <div v-if="loading" class="flex items-center justify-center h-full">
+                                <span class="animate-spin inline-block w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full"></span>
+                                <span class="ml-2 text-sm text-gray-500">@lang('imageupload::app.admin.media-manager.loading')</span>
+                            </div>
+
+                            <div v-else-if="images.length === 0" class="flex items-center justify-center h-full">
+                                <p class="text-sm text-gray-400">@lang('imageupload::app.admin.media-manager.no_images')</p>
+                            </div>
+
+                            <div v-else class="grid grid-cols-4 gap-3">
+                                <div
+                                    v-for="image in images"
+                                    :key="image.path"
+                                    class="relative group border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden cursor-pointer aspect-square"
+                                    :class="{ 'ring-2 ring-blue-500': selectedImage && selectedImage.path === image.path }"
+                                    @click="selectImage(image)"
+                                >
+                                    <img
+                                        :src="image.url"
+                                        :alt="image.name"
+                                        class="w-full h-full object-cover"
+                                    />
+                                    <!-- Delete Button -->
+                                    <button
+                                        type="button"
+                                        class="absolute top-1 right-1 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs"
+                                        @click.stop="confirmDeleteImage(image)"
+                                        title="@lang('imageupload::app.admin.media-manager.delete')"
+                                    >
+                                        ✕
+                                    </button>
+                                    <!-- Selected Check -->
+                                    <div
+                                        v-if="selectedImage && selectedImage.path === image.path"
+                                        class="absolute top-1 right-1 bg-blue-500 text-white rounded-full w-5 h-5 flex items-center justify-center"
+                                    >
+                                        <span class="text-xs">&#10003;</span>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </x-slot>
+
+            <!-- Modal Footer -->
+            <x-slot:footer>
+                <div class="flex items-center justify-between w-full">
+                    <p v-if="selectedImage" class="text-sm text-gray-600 dark:text-gray-400 truncate max-w-xs">
+                        @lang('imageupload::app.admin.media-manager.selected'): @{{ selectedImage.name }}
+                    </p>
+                    <div class="flex gap-2">
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="close"
+                        >
+                            @lang('imageupload::app.admin.media-manager.cancel')
+                        </button>
+                        <button
+                            type="button"
+                            class="primary-button"
+                            :disabled="!selectedImage"
+                            @click="insertImage"
+                        >
+                            @lang('imageupload::app.admin.media-manager.insert')
+                        </button>
+                    </div>
+                </div>
+            </x-slot>
+        </x-admin::modal>
+    </div>
+</script>
+
+<script type="module">
+    app.component('v-media-manager', {
+        template: '#v-media-manager-template',
+
+        props: {
+            onInsert: {
+                type: Function,
+                default: null,
+            },
+        },
+
+        data() {
+            return {
+                folders: [],
+                images: [],
+                currentFolderId: 0,
+                currentFolderName: '',
+                selectedImage: null,
+                showCreateFolder: false,
+                newFolderName: '',
+                isDragOver: false,
+                uploading: false,
+                loading: false,
+            };
+        },
+
+        methods: {
+            open() {
+                this.$refs.mediaManagerModal.open();
+                this.loadFolders();
+                this.loadImages();
+            },
+
+            close() {
+                this.$refs.mediaManagerModal.close();
+                this.selectedImage = null;
+                this.showCreateFolder = false;
+                this.newFolderName = '';
+            },
+
+            toggle() {
+                this.$refs.mediaManagerModal.toggle();
+                if (this.$refs.mediaManagerModal.isOpen) {
+                    this.loadFolders();
+                    this.loadImages();
+                }
+            },
+
+            // Load folders from API (database)
+            async loadFolders() {
+                try {
+                    const response = await fetch('{{ route("admin.image_upload.folders") }}', {
+                        headers: {
+                            'Accept': 'application/json',
+                            'X-CSRF-TOKEN': '{{ csrf_token() }}',
+                        },
+                    });
+                    const data = await response.json();
+                    if (data.success) {
+                        this.folders = data.data.folders || [];
+                    }
+                } catch (error) {
+                    console.error('Failed to load folders:', error);
+                }
+            },
+
+            // Load images from current folder (database)
+            async loadImages() {
+                this.loading = true;
+                try {
+                    const url = new URL('{{ route("admin.image_upload.images") }}', window.location.origin);
+                    url.searchParams.set('folder_id', this.currentFolderId);
+
+                    const response = await fetch(url.toString(), {
+                        headers: {
+                            'Accept': 'application/json',
+                            'X-CSRF-TOKEN': '{{ csrf_token() }}',
+                        },
+                    });
+                    const data = await response.json();
+                    if (data.success) {
+                        this.images = data.data || [];
+                    }
+                } catch (error) {
+                    console.error('Failed to load images:', error);
+                } finally {
+                    this.loading = false;
+                }
+            },
+
+            // Select a folder by id
+            selectFolder(folderId) {
+                this.currentFolderId = folderId;
+                this.selectedImage = null;
+                if (folderId === 0) {
+                    this.currentFolderName = '';
+                } else {
+                    const folder = this.folders.find(f => f.id === folderId);
+                    this.currentFolderName = folder ? folder.name : '';
+                }
+                this.loadImages();
+            },
+
+            // Create a new folder (database only)
+            async createFolder() {
+                if (!this.newFolderName.trim()) return;
+
+                try {
+                    const response = await fetch('{{ route("admin.image_upload.folder.create") }}', {
+                        method: 'POST',
+                        headers: {
+                            'Content-Type': 'application/json',
+                            'Accept': 'application/json',
+                            'X-CSRF-TOKEN': '{{ csrf_token() }}',
+                        },
+                        body: JSON.stringify({
+                            folder_name: this.newFolderName.trim(),
+                            parent_folder: '',
+                        }),
+                    });
+
+                    const data = await response.json();
+                    if (data.success) {
+                        this.$emitter.emit('add-flash', {
+                            type: 'success',
+                            message: '@lang('imageupload::app.admin.media-manager.folder_created')',
+                        });
+                        this.newFolderName = '';
+                        this.showCreateFolder = false;
+                        await this.loadFolders();
+                    } else {
+                        this.$emitter.emit('add-flash', {
+                            type: 'error',
+                            message: data.message || 'Failed to create folder',
+                        });
+                    }
+                } catch (error) {
+                    console.error('Failed to create folder:', error);
+                }
+            },
+
+            // Confirm delete folder
+            confirmDeleteFolder(folder) {
+                if (!confirm('@lang('imageupload::app.admin.media-manager.confirm_delete_folder')')) {
+                    return;
+                }
+                this.deleteFolder(folder);
+            },
+
+            // Delete folder (database + S3 images)
+            async deleteFolder(folder) {
+                try {
+                    const response = await fetch('{{ route("admin.image_upload.folder.delete") }}', {
+                        method: 'DELETE',
+                        headers: {
+                            'Content-Type': 'application/json',
+                            'Accept': 'application/json',
+                            'X-CSRF-TOKEN': '{{ csrf_token() }}',
+                        },
+                        body: JSON.stringify({
+                            folder_id: folder.id,
+                        }),
+                    });
+
+                    const data = await response.json();
+                    if (data.success) {
+                        this.$emitter.emit('add-flash', {
+                            type: 'success',
+                            message: '@lang('imageupload::app.admin.media-manager.folder_deleted')',
+                        });
+
+                        if (this.currentFolderId === folder.id) {
+                            this.currentFolderId = 0;
+                            this.currentFolderName = '';
+                        }
+                        await this.loadFolders();
+                        await this.loadImages();
+                    }
+                } catch (error) {
+                    console.error('Failed to delete folder:', error);
+                }
+            },
+
+            // Select an image
+            selectImage(image) {
+                if (this.selectedImage && this.selectedImage.path === image.path) {
+                    this.selectedImage = null;
+                } else {
+                    this.selectedImage = image;
+                }
+            },
+
+            // Confirm delete image
+            confirmDeleteImage(image) {
+                if (!confirm('@lang('imageupload::app.admin.media-manager.confirm_delete_image')')) {
+                    return;
+                }
+                this.deleteImage(image);
+            },
+
+            // Delete image (S3 + database)
+            async deleteImage(image) {
+                try {
+                    const response = await fetch('{{ route("admin.image_upload.delete") }}', {
+                        method: 'DELETE',
+                        headers: {
+                            'Content-Type': 'application/json',
+                            'Accept': 'application/json',
+                            'X-CSRF-TOKEN': '{{ csrf_token() }}',
+                        },
+                        body: JSON.stringify({
+                            path: image.path,
+                        }),
+                    });
+
+                    const data = await response.json();
+                    if (data.success) {
+                        this.$emitter.emit('add-flash', {
+                            type: 'success',
+                            message: '@lang('imageupload::app.admin.media-manager.image_deleted')',
+                        });
+
+                        if (this.selectedImage && this.selectedImage.path === image.path) {
+                            this.selectedImage = null;
+                        }
+                        await this.loadImages();
+                    }
+                } catch (error) {
+                    console.error('Failed to delete image:', error);
+                }
+            },
+
+            // Handle file drop
+            handleDrop(event) {
+                this.isDragOver = false;
+                const files = Array.from(event.dataTransfer.files);
+                this.uploadFiles(files);
+            },
+
+            // Handle file select
+            handleFileSelect(event) {
+                const files = Array.from(event.target.files);
+                this.uploadFiles(files);
+                event.target.value = '';
+            },
+
+            // Upload files to S3, record in database
+            async uploadFiles(files) {
+                if (files.length === 0) return;
+
+                this.uploading = true;
+
+                try {
+                    const formData = new FormData();
+                    files.forEach(file => {
+                        formData.append('images[]', file);
+                    });
+
+                    // 传当前文件夹名作为 directory,后端会用它在 image_uploads 表关联 folder_id
+                    if (this.currentFolderId > 0 && this.currentFolderName) {
+                        formData.append('directory', this.currentFolderName);
+                    }
+
+                    const response = await fetch('{{ route("admin.image_upload.upload_multiple") }}', {
+                        method: 'POST',
+                        body: formData,
+                        headers: {
+                            'X-CSRF-TOKEN': '{{ csrf_token() }}',
+                        },
+                    });
+
+                    const data = await response.json();
+                    if (data.success) {
+                        this.$emitter.emit('add-flash', {
+                            type: 'success',
+                            message: '@lang('imageupload::app.admin.media-manager.upload_success')',
+                        });
+                        await this.loadImages();
+                    } else {
+                        this.$emitter.emit('add-flash', {
+                            type: 'error',
+                            message: data.message || 'Upload failed',
+                        });
+                    }
+                } catch (error) {
+                    console.error('Upload failed:', error);
+                    this.$emitter.emit('add-flash', {
+                        type: 'error',
+                        message: 'Upload failed',
+                    });
+                } finally {
+                    this.uploading = false;
+                }
+            },
+
+            // Insert selected image into TinyMCE
+            insertImage() {
+                if (!this.selectedImage) return;
+
+                if (this.onInsert) {
+                    this.onInsert(this.selectedImage.url);
+                }
+
+                this.close();
+            },
+        },
+    });
+</script>

+ 6 - 0
packages/Longyi/ImageUpload/src/Routes/admin-routes.php

@@ -9,5 +9,11 @@ Route::group(['middleware' => ['web', 'admin'], 'prefix' => config('app.admin_pa
         Route::post('upload-multiple', 'uploadMultiple')->name('admin.image_upload.upload_multiple');
         Route::delete('delete', 'delete')->name('admin.image_upload.delete');
         Route::get('url', 'getUrl')->name('admin.image_upload.get_url');
+
+        // Folder management routes
+        Route::get('folders', 'getFolders')->name('admin.image_upload.folders');
+        Route::post('folder/create', 'createFolder')->name('admin.image_upload.folder.create');
+        Route::delete('folder/delete', 'deleteFolder')->name('admin.image_upload.folder.delete');
+        Route::get('images', 'getImages')->name('admin.image_upload.images');
     });
 });

Разница между файлами не показана из-за своего большого размера
+ 233 - 198
packages/Webkul/Admin/src/Resources/views/components/tinymce/index.blade.php