chengwl il y a 2 jours
Parent
commit
50f9ed8601

+ 1 - 0
composer.json

@@ -45,6 +45,7 @@
         "prettus/l5-repository": "^2.6",
         "pusher/pusher-php-server": "^7.0",
         "shetabit/visitor": "^4.1",
+        "spatie/laravel-medialibrary": "^11.21",
         "spatie/laravel-responsecache": "^7.4",
         "spatie/laravel-sitemap": "^7.3",
         "stevebauman/purify": "^6.3"

+ 239 - 1
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "5b4ecb6b96b299dce930ca9b3090436b",
+    "content-hash": "d3db3887e839723d5366412b0ebe8f01",
     "packages": [
         {
             "name": "api-platform/documentation",
@@ -8790,6 +8790,244 @@
             ],
             "time": "2025-02-24T09:20:47+00:00"
         },
+        {
+            "name": "spatie/image",
+            "version": "3.9.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/spatie/image.git",
+                "reference": "6a322b5e9268e3903d4fb6e1ff08b7dcc3aa9429"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/spatie/image/zipball/6a322b5e9268e3903d4fb6e1ff08b7dcc3aa9429",
+                "reference": "6a322b5e9268e3903d4fb6e1ff08b7dcc3aa9429",
+                "shasum": ""
+            },
+            "require": {
+                "ext-exif": "*",
+                "ext-json": "*",
+                "ext-mbstring": "*",
+                "php": "^8.2",
+                "spatie/image-optimizer": "^1.7.5",
+                "spatie/temporary-directory": "^2.2",
+                "symfony/process": "^6.4|^7.0|^8.0"
+            },
+            "require-dev": {
+                "ext-gd": "*",
+                "ext-imagick": "*",
+                "laravel/sail": "^1.34",
+                "pestphp/pest": "^3.0|^4.0",
+                "phpstan/phpstan": "^1.10.50",
+                "spatie/pest-plugin-snapshots": "^2.1",
+                "spatie/pixelmatch-php": "^1.0",
+                "spatie/ray": "^1.40.1",
+                "symfony/var-dumper": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Spatie\\Image\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Freek Van der Herten",
+                    "email": "freek@spatie.be",
+                    "homepage": "https://spatie.be",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Manipulate images with an expressive API",
+            "homepage": "https://github.com/spatie/image",
+            "keywords": [
+                "image",
+                "spatie"
+            ],
+            "support": {
+                "source": "https://github.com/spatie/image/tree/3.9.4"
+            },
+            "funding": [
+                {
+                    "url": "https://spatie.be/open-source/support-us",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/spatie",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-03-13T14:23:45+00:00"
+        },
+        {
+            "name": "spatie/image-optimizer",
+            "version": "1.8.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/spatie/image-optimizer.git",
+                "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/spatie/image-optimizer/zipball/2ad9ac7c19501739183359ae64ea6c15869c23d9",
+                "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9",
+                "shasum": ""
+            },
+            "require": {
+                "ext-fileinfo": "*",
+                "php": "^7.3|^8.0",
+                "psr/log": "^1.0 | ^2.0 | ^3.0",
+                "symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
+            },
+            "require-dev": {
+                "pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
+                "phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
+                "symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Spatie\\ImageOptimizer\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Freek Van der Herten",
+                    "email": "freek@spatie.be",
+                    "homepage": "https://spatie.be",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Easily optimize images using PHP",
+            "homepage": "https://github.com/spatie/image-optimizer",
+            "keywords": [
+                "image-optimizer",
+                "spatie"
+            ],
+            "support": {
+                "issues": "https://github.com/spatie/image-optimizer/issues",
+                "source": "https://github.com/spatie/image-optimizer/tree/1.8.1"
+            },
+            "time": "2025-11-26T10:57:19+00:00"
+        },
+        {
+            "name": "spatie/laravel-medialibrary",
+            "version": "11.21.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/spatie/laravel-medialibrary.git",
+                "reference": "d6e2595033ffd130d4dd5d124510ab3304794c44"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/d6e2595033ffd130d4dd5d124510ab3304794c44",
+                "reference": "d6e2595033ffd130d4dd5d124510ab3304794c44",
+                "shasum": ""
+            },
+            "require": {
+                "composer/semver": "^3.4",
+                "ext-exif": "*",
+                "ext-fileinfo": "*",
+                "ext-json": "*",
+                "illuminate/bus": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/conditionable": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/console": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/database": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/pipeline": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/support": "^10.2|^11.0|^12.0|^13.0",
+                "maennchen/zipstream-php": "^3.1",
+                "php": "^8.2",
+                "spatie/image": "^3.3.2",
+                "spatie/laravel-package-tools": "^1.16.1",
+                "spatie/temporary-directory": "^2.2",
+                "symfony/console": "^6.4.1|^7.0|^8.0"
+            },
+            "conflict": {
+                "php-ffmpeg/php-ffmpeg": "<0.6.1"
+            },
+            "require-dev": {
+                "aws/aws-sdk-php": "^3.293.10",
+                "ext-imagick": "*",
+                "ext-pdo_sqlite": "*",
+                "ext-zip": "*",
+                "guzzlehttp/guzzle": "^7.8.1",
+                "larastan/larastan": "^2.7|^3.0",
+                "league/flysystem-aws-s3-v3": "^3.22",
+                "mockery/mockery": "^1.6.7",
+                "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
+                "pestphp/pest": "^2.36|^3.0|^4.0",
+                "phpstan/extension-installer": "^1.3.1",
+                "spatie/laravel-ray": "^1.33",
+                "spatie/pdf-to-image": "^2.2|^3.0",
+                "spatie/pest-expectations": "^1.13",
+                "spatie/pest-plugin-snapshots": "^2.1"
+            },
+            "suggest": {
+                "league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage",
+                "php-ffmpeg/php-ffmpeg": "Required for generating video thumbnails",
+                "spatie/pdf-to-image": "Required for generating thumbnails of PDFs and SVGs"
+            },
+            "type": "library",
+            "extra": {
+                "laravel": {
+                    "providers": [
+                        "Spatie\\MediaLibrary\\MediaLibraryServiceProvider"
+                    ]
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Spatie\\MediaLibrary\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Freek Van der Herten",
+                    "email": "freek@spatie.be",
+                    "homepage": "https://spatie.be",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Associate files with Eloquent models",
+            "homepage": "https://github.com/spatie/laravel-medialibrary",
+            "keywords": [
+                "cms",
+                "conversion",
+                "downloads",
+                "images",
+                "laravel",
+                "laravel-medialibrary",
+                "media",
+                "spatie"
+            ],
+            "support": {
+                "issues": "https://github.com/spatie/laravel-medialibrary/issues",
+                "source": "https://github.com/spatie/laravel-medialibrary/tree/11.21.0"
+            },
+            "funding": [
+                {
+                    "url": "https://spatie.be/open-source/support-us",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/spatie",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-02-21T15:58:56+00:00"
+        },
         {
             "name": "spatie/laravel-package-tools",
             "version": "1.19.0",

+ 32 - 0
database/migrations/2026_04_10_064108_create_media_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('media', function (Blueprint $table) {
+            $table->id();
+
+            $table->morphs('model');
+            $table->uuid()->nullable()->unique();
+            $table->string('collection_name');
+            $table->string('name');
+            $table->string('file_name');
+            $table->string('mime_type')->nullable();
+            $table->string('disk');
+            $table->string('conversions_disk')->nullable();
+            $table->unsignedBigInteger('size');
+            $table->json('manipulations');
+            $table->json('custom_properties');
+            $table->json('generated_conversions');
+            $table->json('responsive_images');
+            $table->unsignedInteger('order_column')->nullable()->index();
+
+            $table->nullableTimestamps();
+        });
+    }
+};

+ 7 - 2
packages/Longyi/Core/src/Contracts/ProductVariant.php

@@ -25,7 +25,12 @@ interface ProductVariant
     public function getEffectivePrice(?int $customerGroupId = null, ?int $channelId = null): float;
 
     /**
-     * Get images for this variant (many-to-many with product_images).
+     * Pivot relation to Media entries via product_variant_images.
      */
-    public function images();
+    public function variantImages();
+
+    /**
+     * Get all images (formatted) for this variant.
+     */
+    public function getAllImages(): \Illuminate\Support\Collection;
 }

+ 5 - 5
packages/Longyi/Core/src/Database/Migrations/2026_03_07_000001_create_product_variant_images_table.php

@@ -11,7 +11,7 @@ return new class extends Migration
         Schema::create('product_variant_images', function (Blueprint $table) {
             $table->increments('id');
             $table->unsignedInteger('product_variant_id');
-            $table->unsignedInteger('product_image_id');
+            $table->unsignedBigInteger('media_id');
             $table->integer('position')->default(0);
 
             $table->foreign('product_variant_id')
@@ -19,13 +19,13 @@ return new class extends Migration
                 ->on('product_variants')
                 ->onDelete('cascade');
 
-            $table->foreign('product_image_id')
+            $table->foreign('media_id')
                 ->references('id')
-                ->on('product_images')
+                ->on('media')
                 ->onDelete('cascade');
 
-            $table->unique(['product_variant_id', 'product_image_id'], 'variant_image_unique');
-            $table->index('product_image_id', 'idx_image_id');
+            $table->unique(['product_variant_id', 'media_id'], 'variant_media_unique');
+            $table->index('media_id', 'idx_media_id');
         });
     }
 

+ 25 - 0
packages/Longyi/Core/src/Database/Migrations/2026_03_07_000002_add_meta_to_product_images_table.php

@@ -0,0 +1,25 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('product_images', function (Blueprint $table) {
+            $table->string('label')->nullable()->after('position')->comment('图片 alt / label 文本');
+            $table->boolean('is_base_image')->default(false)->after('label')->comment('是否为主图');
+            $table->boolean('is_small_image')->default(false)->after('is_base_image')->comment('是否为小图');
+            $table->boolean('is_thumbnail')->default(false)->after('is_small_image')->comment('是否为缩略图');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('product_images', function (Blueprint $table) {
+            $table->dropColumn(['label', 'is_base_image', 'is_small_image', 'is_thumbnail']);
+        });
+    }
+};

+ 162 - 0
packages/Longyi/Core/src/Helpers/ProductImage.php

@@ -0,0 +1,162 @@
+<?php
+
+namespace Longyi\Core\Helpers;
+
+use Webkul\Product\ProductImage as BaseProductImage;
+
+/**
+ * Extended ProductImage helper.
+ *
+ * Adds support for role-based image retrieval:
+ *   - getBaseImage($product)      → image with is_base_image = true (falls back to first)
+ *   - getSmallImage($product)     → image with is_small_image = true (falls back to first)
+ *   - getThumbnailImage($product) → image with is_thumbnail = true  (falls back to first)
+ *
+ * Also overrides getProductBaseImage() to honour the is_base_image flag.
+ */
+class ProductImage extends BaseProductImage
+{
+    /**
+     * Get the image designated as the base image.
+     * Falls back to the first image if none is explicitly set.
+     *
+     * @param  \Webkul\Product\Contracts\Product  $product
+     * @return array|null
+     */
+    public function getBaseImage($product): ?array
+    {
+        if (! $product) {
+            return null;
+        }
+
+        $image = $product->images->first(fn ($img) => $img->is_base_image)
+            ?? $product->images->first();
+
+        return $image ? $this->getCachedImageUrlsPublic($image->path) : null;
+    }
+
+    /**
+     * Get the image designated as the small image.
+     * Falls back to the first image if none is explicitly set.
+     *
+     * @param  \Webkul\Product\Contracts\Product  $product
+     * @return array|null
+     */
+    public function getSmallImage($product): ?array
+    {
+        if (! $product) {
+            return null;
+        }
+
+        $image = $product->images->first(fn ($img) => $img->is_small_image)
+            ?? $product->images->first();
+
+        return $image ? $this->getCachedImageUrlsPublic($image->path) : null;
+    }
+
+    /**
+     * Get the image designated as the thumbnail.
+     * Falls back to the first image if none is explicitly set.
+     *
+     * @param  \Webkul\Product\Contracts\Product  $product
+     * @return array|null
+     */
+    public function getThumbnailImage($product): ?array
+    {
+        if (! $product) {
+            return null;
+        }
+
+        $image = $product->images->first(fn ($img) => $img->is_thumbnail)
+            ?? $product->images->first();
+
+        return $image ? $this->getCachedImageUrlsPublic($image->path) : null;
+    }
+
+    /**
+     * Override: honour is_base_image flag when loading the "base" product image.
+     *
+     * @param  \Webkul\Product\Contracts\Product  $product
+     * @param  array|null  $galleryImages
+     * @return array|null
+     */
+    public function getProductBaseImage($product, ?array $galleryImages = null)
+    {
+        if (! $product) {
+            return null;
+        }
+
+        // If gallery images are already resolved externally, use the first one (original behaviour)
+        if ($galleryImages) {
+            return $galleryImages[0];
+        }
+
+        return $this->loadBaseImageFromProduct($product);
+    }
+
+    /**
+     * Load the base image from the product, preferring is_base_image = true.
+     */
+    protected function loadBaseImageFromProduct($product): ?array
+    {
+        $images = $product?->images;
+
+        if (! $images || $images->isEmpty()) {
+            return $this->getFallbackImageUrlsPublic();
+        }
+
+        $baseImage = $images->first(fn ($img) => $img->is_base_image) ?? $images->first();
+
+        return $this->getCachedImageUrlsPublic($baseImage->path);
+    }
+
+    /**
+     * Expose the private getCachedImageUrls() from the parent via reflection
+     * by duplicating the logic here (parent method is private, not accessible).
+     */
+    protected function getCachedImageUrlsPublic(string $path): array
+    {
+        if (! $this->isDriverLocalPublic()) {
+            return [
+                'small_image_url'    => \Illuminate\Support\Facades\Storage::url($path),
+                'medium_image_url'   => \Illuminate\Support\Facades\Storage::url($path),
+                'large_image_url'    => \Illuminate\Support\Facades\Storage::url($path),
+                'original_image_url' => \Illuminate\Support\Facades\Storage::url($path),
+            ];
+        }
+
+        return [
+            'small_image_url'    => url('cache/small/'.$path),
+            'medium_image_url'   => url('cache/medium/'.$path),
+            'large_image_url'    => url('cache/large/'.$path),
+            'original_image_url' => url('cache/original/'.$path),
+        ];
+    }
+
+    protected function getFallbackImageUrlsPublic(): array
+    {
+        $smallImageUrl = core()->getConfigData('catalog.products.cache_small_image.url')
+            ? \Illuminate\Support\Facades\Storage::url(core()->getConfigData('catalog.products.cache_small_image.url'))
+            : bagisto_asset('images/small-product-placeholder.webp', 'shop');
+
+        $mediumImageUrl = core()->getConfigData('catalog.products.cache_medium_image.url')
+            ? \Illuminate\Support\Facades\Storage::url(core()->getConfigData('catalog.products.cache_medium_image.url'))
+            : bagisto_asset('images/medium-product-placeholder.webp', 'shop');
+
+        $largeImageUrl = core()->getConfigData('catalog.products.cache_large_image.url')
+            ? \Illuminate\Support\Facades\Storage::url(core()->getConfigData('catalog.products.cache_large_image.url'))
+            : bagisto_asset('images/large-product-placeholder.webp', 'shop');
+
+        return [
+            'small_image_url'    => $smallImageUrl,
+            'medium_image_url'   => $mediumImageUrl,
+            'large_image_url'    => $largeImageUrl,
+            'original_image_url' => bagisto_asset('images/large-product-placeholder.webp', 'shop'),
+        ];
+    }
+
+    protected function isDriverLocalPublic(): bool
+    {
+        return \Illuminate\Support\Facades\Storage::getAdapter() instanceof \League\Flysystem\Local\LocalFilesystemAdapter;
+    }
+}

+ 93 - 38
packages/Longyi/Core/src/Http/Controllers/Admin/FlexibleVariantController.php

@@ -4,13 +4,9 @@ namespace Longyi\Core\Http\Controllers\Admin;
 
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
-use Illuminate\Http\UploadedFile;
 use Illuminate\Routing\Controller;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Storage;
-use Illuminate\Support\Str;
-use Intervention\Image\ImageManager;
 use Longyi\Core\Helpers\FlexibleVariantOption;
 use Longyi\Core\Models\ProductOption;
 use Longyi\Core\Models\ProductOptionValue;
@@ -18,6 +14,7 @@ use Longyi\Core\Models\ProductVariant;
 use Longyi\Core\Repositories\ProductOptionRepository;
 use Longyi\Core\Repositories\ProductOptionValueRepository;
 use Longyi\Core\Repositories\ProductVariantRepository;
+use Spatie\MediaLibrary\MediaCollections\Models\Media;
 use Webkul\Product\Helpers\Indexers\Price as PriceIndexer;
 use Webkul\Product\Repositories\ProductRepository;
 
@@ -351,7 +348,7 @@ class FlexibleVariantController extends Controller
             'variants.*.values' => 'array',
             'variants.*.values.*.code' => 'required|string',
             'variants.*.image_ids' => 'array',
-            'variants.*.image_ids.*' => 'integer|exists:product_images,id',
+            'variants.*.image_ids.*' => 'integer|exists:media,id',
         ]);
 
 
@@ -420,8 +417,8 @@ class FlexibleVariantController extends Controller
             if (isset($variantData['image_ids'])) {
                 $imageSync = collect($variantData['image_ids'])
                     ->values()
-                    ->mapWithKeys(fn ($imageId, $i) => [$imageId => ['position' => $i]]);
-                $variant->images()->sync($imageSync);
+                    ->mapWithKeys(fn ($mediaId, $i) => [$mediaId => ['position' => $i]]);
+                $variant->variantImages()->sync($imageSync);
             }
 
             $variants[$variantIndex]['variant_id'] = $variant->id;
@@ -660,71 +657,129 @@ class FlexibleVariantController extends Controller
     /**
      * Sync images for a variant.
      *
-     * Accepts a mix of existing product_image IDs and new file uploads.
-     * New files are stored into the product gallery first, then all IDs
-     * are synced to the variant via the pivot table.
-     *
      * POST /variants/{id}/images
      * Body (multipart/form-data):
-     *   image_ids[]   - existing product_image IDs to keep/attach
-     *   uploads[]     - new image files to upload
+     *   media_ids[]  - existing Media IDs (spatie/medialibrary) to attach/keep
+     *   uploads[]    - new image files to upload (stored via medialibrary on the variant)
+     *
+     * Workflow:
+     *   1. New uploads are added to the variant's 'variant_images' media collection.
+     *   2. All resulting Media IDs (new + existing) are synced into
+     *      the product_variant_images pivot table with position order.
      */
     public function syncVariantImages(int $id, Request $request): JsonResponse
     {
         $this->validate($request, [
-            'image_ids'   => 'array',
-            'image_ids.*' => 'integer|exists:product_images,id',
+            'media_ids'   => 'array',
+            'media_ids.*' => 'integer|exists:media,id',
             'uploads'     => 'array',
             'uploads.*'   => 'image|max:5120',
         ]);
 
         $variant = ProductVariant::findOrFail($id);
-        $product = $variant->product;
 
-        $imageIds = collect($request->input('image_ids', []));
+        $mediaIds = collect($request->input('media_ids', []));
 
         if ($request->hasFile('uploads')) {
             foreach ($request->file('uploads') as $file) {
-                $newImage = $this->storeProductImage($file, $product);
-                $imageIds->push($newImage->id);
+                $media = $variant
+                    ->addMedia($file)
+                    ->toMediaCollection(ProductVariant::IMAGES_COLLECTION);
+
+                $mediaIds->push($media->id);
             }
         }
 
-        $syncData = $imageIds->values()->mapWithKeys(
-            fn ($imageId, $i) => [$imageId => ['position' => $i]]
+        $syncData = $mediaIds->values()->mapWithKeys(
+            fn ($mediaId, $i) => [$mediaId => ['position' => $i]]
         );
 
-        $variant->images()->sync($syncData);
+        $variant->variantImages()->sync($syncData);
 
         return response()->json([
             'success' => true,
-            'data'    => $variant->load('images')->images,
+            'data'    => $this->formatVariantImages($variant),
+        ]);
+    }
+
+    /**
+     * Upload a new image for a variant without replacing existing ones.
+     *
+     * POST /variants/{id}/images/upload
+     */
+    public function uploadVariantImage(int $id, Request $request): JsonResponse
+    {
+        $this->validate($request, [
+            'file' => 'required|image|max:5120',
         ]);
+
+        $variant = ProductVariant::findOrFail($id);
+
+        $media = $variant
+            ->addMedia($request->file('file'))
+            ->toMediaCollection(ProductVariant::IMAGES_COLLECTION);
+
+        // Append to pivot table at the end
+        $nextPosition = $variant->variantImages()->count();
+        $variant->variantImages()->attach($media->id, ['position' => $nextPosition]);
+
+        return response()->json([
+            'success' => true,
+            'data'    => $this->formatMediaItem($media),
+        ], 201);
     }
 
     /**
-     * Store an uploaded file into the product gallery (product_images table).
+     * Remove a single media item from a variant (detach from pivot + delete media record).
+     *
+     * DELETE /variants/{id}/images/{mediaId}
      */
-    protected function storeProductImage(UploadedFile $file, $product): \Webkul\Product\Models\ProductImage
+    public function removeVariantImage(int $id, int $mediaId): JsonResponse
     {
-        $directory = 'product/' . $product->id;
-
-        if (Str::contains($file->getMimeType(), 'image')) {
-            $manager = new ImageManager;
-            $image = $manager->make($file)->encode('webp');
-            $path = $directory . '/' . Str::random(40) . '.webp';
-            Storage::put($path, $image);
-        } else {
-            $path = $file->store($directory);
+        $variant = ProductVariant::findOrFail($id);
+
+        $variant->variantImages()->detach($mediaId);
+
+        $media = Media::find($mediaId);
+
+        // Only delete the media record if no other variant still references it
+        if ($media && $variant->variantImages()->where('media_id', $mediaId)->doesntExist()) {
+            $media->delete();
         }
 
-        return $product->images()->create([
-            'type'     => 'images',
-            'path'     => $path,
-            'position' => $product->images()->count(),
+        return response()->json([
+            'success' => true,
+            'message' => 'Image removed successfully',
         ]);
     }
 
+    /**
+     * Format a variant's images for API response.
+     */
+    protected function formatVariantImages(ProductVariant $variant): array
+    {
+        return $variant->variantImages()
+            ->get()
+            ->map(fn ($media) => $this->formatMediaItem($media))
+            ->all();
+    }
+
+    /**
+     * Format a single Media item for API response.
+     */
+    protected function formatMediaItem(Media $media): array
+    {
+        return [
+            'id'           => $media->id,
+            'url'          => $media->getUrl(),
+            'thumb_url'    => $media->hasGeneratedConversion('thumb') ? $media->getUrl('thumb') : $media->getUrl(),
+            'file_name'    => $media->file_name,
+            'mime_type'    => $media->mime_type,
+            'size'         => $media->size,
+            'position'     => $media->pivot?->position,
+        ];
+    }
+
     /**
      * Trigger price reindex for a product and its flexible variants.
      */

+ 104 - 0
packages/Longyi/Core/src/Models/ProductImage.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace Longyi\Core\Models;
+
+use Illuminate\Support\Facades\Storage;
+use Webkul\Product\Models\ProductImage as BaseProductImage;
+use Webkul\Product\Models\ProductProxy;
+
+/**
+ * Extended ProductImage model.
+ *
+ * Adds label, is_base_image, is_small_image, is_thumbnail columns.
+ *
+ * @property string|null $label
+ * @property bool        $is_base_image
+ * @property bool        $is_small_image
+ * @property bool        $is_thumbnail
+ */
+class ProductImage extends BaseProductImage
+{
+    /**
+     * @var array
+     */
+    protected $fillable = [
+        'type',
+        'path',
+        'product_id',
+        'position',
+        'label',
+        'is_base_image',
+        'is_small_image',
+        'is_thumbnail',
+    ];
+
+    /**
+     * @var array
+     */
+    protected $casts = [
+        'is_base_image'  => 'boolean',
+        'is_small_image' => 'boolean',
+        'is_thumbnail'   => 'boolean',
+    ];
+
+    /**
+     * @var array
+     */
+    protected $appends = ['url', 'roles'];
+
+    /**
+     * Get image roles as a plain array for easy front-end consumption.
+     *
+     * @return array<string>
+     */
+    public function getRolesAttribute(): array
+    {
+        $roles = [];
+
+        if ($this->is_base_image) {
+            $roles[] = 'base_image';
+        }
+
+        if ($this->is_small_image) {
+            $roles[] = 'small_image';
+        }
+
+        if ($this->is_thumbnail) {
+            $roles[] = 'thumbnail';
+        }
+
+        return $roles;
+    }
+
+    /**
+     * Convenience: check if this image has a specific role.
+     */
+    public function hasRole(string $role): bool
+    {
+        return in_array($role, $this->getRolesAttribute(), true);
+    }
+
+    /**
+     * Scope: filter images that are designated as base image.
+     */
+    public function scopeBaseImage($query)
+    {
+        return $query->where('is_base_image', true);
+    }
+
+    /**
+     * Scope: filter images that are designated as small image.
+     */
+    public function scopeSmallImage($query)
+    {
+        return $query->where('is_small_image', true);
+    }
+
+    /**
+     * Scope: filter images that are designated as thumbnail.
+     */
+    public function scopeThumbnail($query)
+    {
+        return $query->where('is_thumbnail', true);
+    }
+}

+ 69 - 44
packages/Longyi/Core/src/Models/ProductVariant.php

@@ -8,27 +8,32 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\MorphMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Longyi\Core\Contracts\ProductVariant as ProductVariantContract;
+use Spatie\MediaLibrary\HasMedia;
+use Spatie\MediaLibrary\InteractsWithMedia;
+use Spatie\MediaLibrary\MediaCollections\Models\Media;
 use Webkul\Product\Models\Product;
 use Webkul\Product\Models\ProductCustomerGroupPriceProxy;
-use Webkul\Product\Models\ProductImageProxy;
 use Webkul\Product\Models\ProductPriceIndexProxy;
 
 /**
- * @property int    $id
- * @property int    $product_id
- * @property string $sku
- * @property string $name
- * @property float  $price
- * @property float  $cost
- * @property float  $weight
- * @property int    $quantity
+ * @property int     $id
+ * @property int     $product_id
+ * @property string  $sku
+ * @property string  $name
+ * @property float   $price
+ * @property float   $cost
+ * @property float   $weight
+ * @property int     $quantity
  * @property boolean $status
- * @property array  $images
- * @property int    $sort_order
+ * @property int     $sort_order
  */
-class ProductVariant extends Model implements ProductVariantContract
+class ProductVariant extends Model implements ProductVariantContract, HasMedia
 {
     use SoftDeletes;
+    use InteractsWithMedia;
+
+    /** Collection name for variant images. */
+    const IMAGES_COLLECTION = 'variant_images';
 
     /**
      * The table associated with the model.
@@ -64,18 +69,18 @@ class ProductVariant extends Model implements ProductVariantContract
      * @var array
      */
     protected $casts = [
-        'product_id' => 'integer',
-        'price' => 'decimal:4',
-        'compare_price' => 'decimal:4',
-        'special_price' => 'decimal:4',
-        'cost' => 'decimal:4',
-        'weight' => 'decimal:4',
-        'quantity' => 'integer',
-        'status' => 'boolean',
-        'sort_order' => 'integer',
+        'product_id'        => 'integer',
+        'price'             => 'decimal:4',
+        'compare_price'     => 'decimal:4',
+        'special_price'     => 'decimal:4',
+        'cost'              => 'decimal:4',
+        'weight'            => 'decimal:4',
+        'quantity'          => 'integer',
+        'status'            => 'boolean',
+        'sort_order'        => 'integer',
         'special_price_from' => 'date',
-        'special_price_to' => 'date',
-        'deleted_at' => 'datetime',
+        'special_price_to'  => 'date',
+        'deleted_at'        => 'datetime',
     ];
 
     /**
@@ -85,15 +90,24 @@ class ProductVariant extends Model implements ProductVariantContract
     {
         parent::boot();
 
-        // When deleting a variant, detach all option values
         static::deleting(function ($variant) {
             $variant->values()->detach();
-            $variant->images()->detach();
+            $variant->variantImages()->detach();
         });
     }
 
     /**
-     * Get the parent product
+     * Register media collections.
+     * All uploaded variant images are stored in the 'variant_images' collection.
+     */
+    public function registerMediaCollections(): void
+    {
+        $this->addMediaCollection(self::IMAGES_COLLECTION)
+            ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
+    }
+
+    /**
+     * Get the parent product.
      */
     public function product(): BelongsTo
     {
@@ -107,6 +121,7 @@ class ProductVariant extends Model implements ProductVariantContract
     {
         return $this->morphMany(ProductPriceIndexProxy::modelClass(), 'priceable');
     }
+
     public function basePrices(): MorphMany
     {
         return $this->price_indices()->whereNull('customer_group_id');
@@ -121,7 +136,7 @@ class ProductVariant extends Model implements ProductVariantContract
     }
 
     /**
-     * Get all option values for this variant (many-to-many)
+     * Get all option values for this variant (many-to-many).
      */
     public function values(): BelongsToMany
     {
@@ -134,7 +149,31 @@ class ProductVariant extends Model implements ProductVariantContract
     }
 
     /**
-     * Check if variant is saleable
+     * Pivot relation: media entries linked to this variant via product_variant_images.
+     * Use this for attaching/detaching/syncing existing Media records.
+     */
+    public function variantImages(): BelongsToMany
+    {
+        return $this->belongsToMany(
+            Media::class,
+            'product_variant_images',
+            'product_variant_id',
+            'media_id'
+        )->withPivot('position')
+         ->orderByPivot('position');
+    }
+
+    /**
+     * Helper: get all images for this variant (pivot + own media collection merged).
+     * Returns pivot images first (in position order), then any directly-owned media.
+     */
+    public function getAllImages(): \Illuminate\Support\Collection
+    {
+        return $this->variantImages()->get();
+    }
+
+    /**
+     * Check if variant is saleable.
      */
     public function isSaleable(): bool
     {
@@ -192,21 +231,7 @@ class ProductVariant extends Model implements ProductVariantContract
     }
 
     /**
-     * Get the images for this variant (many-to-many with product_images).
-     */
-    public function images(): BelongsToMany
-    {
-        return $this->belongsToMany(
-            ProductImageProxy::modelClass(),
-            'product_variant_images',
-            'product_variant_id',
-            'product_image_id'
-        )->withPivot('position')
-         ->orderByPivot('position');
-    }
-
-    /**
-     * Scope to get only active variants
+     * Scope to get only active variants.
      */
     public function scopeActive($query)
     {
@@ -214,7 +239,7 @@ class ProductVariant extends Model implements ProductVariantContract
     }
 
     /**
-     * Scope to get only saleable variants
+     * Scope to get only saleable variants.
      */
     public function scopeSaleable($query)
     {

+ 20 - 0
packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php

@@ -85,6 +85,19 @@ class LongyiCoreServiceProvider extends ServiceProvider
             \Longyi\Core\Contracts\ProductVariant::class,
             \Longyi\Core\Models\ProductVariant::class
         );
+
+        // Replace Webkul's ProductImage Model with our extended version that adds
+        // label, is_base_image, is_small_image, is_thumbnail support.
+        $this->app->bind(
+            \Webkul\Product\Contracts\ProductImage::class,
+            \Longyi\Core\Models\ProductImage::class
+        );
+
+
+        $this->app->singleton(
+            \Webkul\Product\ProductImage::class,
+            \Longyi\Core\Helpers\ProductImage::class
+        );
     }
 
     /**
@@ -103,5 +116,12 @@ class LongyiCoreServiceProvider extends ServiceProvider
         $this->app->singleton(
             \Longyi\Core\Repositories\ProductVariantRepository::class
         );
+
+        // Override Webkul's ProductImageRepository with our extended version
+        // that supports label, is_base_image, is_small_image, is_thumbnail.
+        $this->app->singleton(
+            \Webkul\Product\Repositories\ProductImageRepository::class,
+            \Longyi\Core\Repositories\ProductImageRepository::class
+        );
     }
 }

+ 128 - 0
packages/Longyi/Core/src/Repositories/ProductImageRepository.php

@@ -0,0 +1,128 @@
+<?php
+
+namespace Longyi\Core\Repositories;
+
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use Intervention\Image\ImageManager;
+use Webkul\Core\Eloquent\Repository;
+
+/**
+ * Extended ProductImageRepository.
+ *
+ * Overrides the Webkul upload pipeline to support the extra columns:
+ *   label, is_base_image, is_small_image, is_thumbnail.
+ *
+ * The extra data is expected inside $data['images']['meta'], keyed by
+ * the same index used in $data['images']['files']:
+ *
+ * $data['images'] = [
+ *     'files' => [
+ *         0 => <UploadedFile>,   // new upload
+ *         7 => 7,                // existing image id to keep
+ *     ],
+ *     'meta' => [
+ *         0 => ['label' => 'Front', 'is_base_image' => true, 'is_small_image' => true, 'is_thumbnail' => false],
+ *         7 => ['label' => 'Back',  'is_base_image' => false, ...],
+ *     ],
+ * ]
+ */
+class ProductImageRepository extends Repository
+{
+    public function model(): string
+    {
+        return \Webkul\Product\Contracts\ProductImage::class;
+    }
+
+    /**
+     * Upload & sync product images including label / role meta.
+     */
+    public function upload(array $data, $product, string $uploadFileType = 'images'): void
+    {
+        $previousIds = $product->images()->pluck('id');
+
+        $position = 0;
+        $files    = $data[$uploadFileType]['files'] ?? [];
+        $meta     = $data[$uploadFileType]['meta']  ?? [];
+
+        foreach ($files as $indexOrModelId => $file) {
+            $imageMeta = $this->extractMeta($meta[$indexOrModelId] ?? []);
+
+            if ($file instanceof UploadedFile) {
+                $path = $this->storeFile($file, $product);
+
+                $this->create(array_merge([
+                    'type'       => $uploadFileType,
+                    'path'       => $path,
+                    'product_id' => $product->id,
+                    'position'   => ++$position,
+                ], $imageMeta));
+            } else {
+                // $file is an existing model ID — keep it, update position & meta
+                if (is_numeric($index = $previousIds->search($indexOrModelId))) {
+                    $previousIds->forget($index);
+                }
+
+                $this->update(array_merge(['position' => ++$position], $imageMeta), $indexOrModelId);
+            }
+        }
+
+        // Delete images that were removed
+        foreach ($previousIds as $modelId) {
+            if ($model = $this->find($modelId)) {
+                Storage::delete($model->path);
+                $this->delete($modelId);
+            }
+        }
+    }
+
+    /**
+     * Update only the meta fields (label / roles) for a single image.
+     * Does NOT change the file or position.
+     */
+    public function updateImageMeta(int $imageId, array $meta): bool
+    {
+        $image = $this->find($imageId);
+
+        if (! $image) {
+            return false;
+        }
+
+        $image->update($this->extractMeta($meta));
+
+        return true;
+    }
+
+    /**
+     * Store a file and return its path.
+     */
+    protected function storeFile(UploadedFile $file, $product): string
+    {
+        $directory = 'product/' . $product->id;
+
+        if (Str::contains($file->getMimeType(), 'image')) {
+            $manager = new ImageManager;
+            $image   = $manager->make($file)->encode('webp');
+            $path    = $directory . '/' . Str::random(40) . '.webp';
+            Storage::put($path, $image);
+
+            return $path;
+        }
+
+        return $file->store($directory);
+    }
+
+    /**
+     * Extract and normalise the meta fields from raw input.
+     */
+    protected function extractMeta(array $raw): array
+    {
+        return [
+            'label'          => $raw['label']          ?? null,
+            'is_base_image'  => ! empty($raw['is_base_image']),
+            'is_small_image' => ! empty($raw['is_small_image']),
+            'is_thumbnail'   => ! empty($raw['is_thumbnail']),
+        ];
+    }
+}

+ 2 - 0
packages/Longyi/Core/src/Routes/admin-routes.php

@@ -33,6 +33,8 @@ Route::group(['middleware' => ['admin']], function () {
             Route::put('/{id}', [FlexibleVariantController::class, 'updateVariant'])->name('admin.flexible_variant.variants.update');
             Route::delete('/{id}', [FlexibleVariantController::class, 'deleteVariant'])->name('admin.flexible_variant.variants.destroy');
             Route::post('/{id}/images', [FlexibleVariantController::class, 'syncVariantImages'])->name('admin.flexible_variant.variants.images.sync');
+            Route::post('/{id}/images/upload', [FlexibleVariantController::class, 'uploadVariantImage'])->name('admin.flexible_variant.variants.images.upload');
+            Route::delete('/{id}/images/{mediaId}', [FlexibleVariantController::class, 'removeVariantImage'])->name('admin.flexible_variant.variants.images.remove');
 
             // Bulk operations
             Route::post('/bulk/quantities', [FlexibleVariantController::class, 'bulkUpdateQuantities'])->name('admin.flexible_variant.variants.bulk.quantities');