Ver Fonte

Merge branch 'dev' of http://gogs.hnwmzp.cn/chengwenliang/nshop into dev

chengwl há 2 dias atrás
pai
commit
47648cab9d

+ 1 - 1
packages/Longyi/ImageUpload/src/Services/ImageUploadService.php

@@ -28,7 +28,7 @@ class ImageUploadService
         $disk = config('image_upload.default_disk', 's3');
         $disk = config('image_upload.default_disk', 's3');
 
 
         // Generate directory path
         // Generate directory path
-        $uploadDirectory = $directory ?? config('image_upload.upload_directory', 'images/uploads');
+        $uploadDirectory = $directory ?? config('image_upload.upload_directory', 'uploads');
         $path = $uploadDirectory . '/' . date('Ym/d');
         $path = $uploadDirectory . '/' . date('Ym/d');
 
 
         // Process and store the image
         // Process and store the image

+ 1 - 1
packages/Webkul/Admin/src/Http/Controllers/Settings/ThemeController.php

@@ -134,7 +134,7 @@ class ThemeController extends Controller
 
 
         $this->themeCustomizationRepository->delete($id);
         $this->themeCustomizationRepository->delete($id);
 
 
-        Storage::deleteDirectory('theme/'.$id);
+        Storage::disk('s3')->deleteDirectory('theme/'.$id);
 
 
         Event::dispatch('theme_customization.delete.after', $id);
         Event::dispatch('theme_customization.delete.after', $id);
 
 

+ 39 - 10
packages/Webkul/Admin/src/Resources/views/settings/themes/edit/category-carousel.blade.php

@@ -104,11 +104,12 @@
                     v-if="options.filters.length"
                     v-if="options.filters.length"
                     v-for="(filter, index) in options.filters"
                     v-for="(filter, index) in options.filters"
                 >
                 >
-                    <!-- Hidden Input -->
+                    <!-- Hidden Input: 多 parent_id 用逗号拼接成一个 input,避开同名覆盖问题 -->
                     <input
                     <input
+                        v-if="index === 0"
                         type="hidden"
                         type="hidden"
-                        :name="'{{ $currentLocale->code }}[options][filters][' + filter.key +']'"
-                        :value="filter.value"
+                        :name="'{{ $currentLocale->code }}[options][filters][' + filter.key + ']'"
+                        :value="getFilterValue(filter.key)"
                     > 
                     > 
                 
                 
                     <!-- Details -->
                     <!-- Details -->
@@ -325,18 +326,35 @@
             created() {
             created() {
                 if (this.options === null) {
                 if (this.options === null) {
                     this.options = { filters: {} };
                     this.options = { filters: {} };
-                }   
+                }
 
 
                 if (! this.options.filters) {
                 if (! this.options.filters) {
                     this.options.filters = {};
                     this.options.filters = {};
                 }
                 }
 
 
-                this.options.filters = Object.keys(this.options.filters)
-                    .filter(key => ! ['sort', 'limit', 'title'].includes(key))
-                    .map(key => ({
-                        key: key,
-                        value: this.options.filters[key]
-                    }));
+                // 把对象形式(保存后)还原成数组形式(用于渲染)
+                // 支持三种格式:
+                //   1. 老格式:单值字符串    "5"
+                //   2. 字符串数组:         ["4","2","3"]
+                //   3. 逗号分隔字符串:      "4,2,3"
+                const raw = this.options.filters;
+                const reserved = ['sort', 'limit', 'title'];
+                const list = [];
+
+                Object.keys(raw).forEach(key => {
+                    if (reserved.includes(key)) return;
+                    let vals = raw[key];
+                    if (typeof vals === 'string') {
+                        vals = vals.indexOf(',') >= 0
+                            ? vals.split(',')
+                            : [vals];
+                    } else if (! Array.isArray(vals)) {
+                        vals = [String(vals)];
+                    }
+                    vals.forEach(v => list.push({ key: key, value: String(v).trim() }));
+                });
+
+                this.options.filters = list;
             },
             },
             
             
             methods: {
             methods: {
@@ -359,6 +377,17 @@
                 handleFilter(event) {
                 handleFilter(event) {
                     this.filters.applied = this.filters.available.find(filter => filter.code == event.target.value);
                     this.filters.applied = this.filters.available.find(filter => filter.code == event.target.value);
                 },
                 },
+
+                /**
+                 * 把同一 key 的多个 filter 值聚合成一个逗号分隔字符串,
+                 * 避开同名表单 input 互相覆盖的问题。
+                 */
+                getFilterValue(key) {
+                    return this.options.filters
+                        .filter(f => f.key === key)
+                        .map(f => f.value)
+                        .join(',');
+                },
             },
             },
         });
         });
     </script>
     </script>

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

@@ -0,0 +1,188 @@
+<?php
+
+namespace Webkul\Shop\Http\Controllers\API;
+
+use Illuminate\Http\JsonResponse;
+use Webkul\Category\Repositories\CategoryRepository;
+use Webkul\Core\Repositories\ChannelRepository;
+use Webkul\Product\Repositories\ProductRepository;
+use Webkul\Theme\Repositories\ThemeCustomizationRepository;
+
+class HomeController extends APIController
+{
+    /**
+     * Create a new controller instance.
+     */
+    public function __construct(
+        protected ThemeCustomizationRepository $themeCustomizationRepository,
+        protected CategoryRepository $categoryRepository,
+        protected ProductRepository $productRepository,
+        protected ChannelRepository $channelRepository,
+    ) {}
+
+    /**
+     * 获取首页完整数据
+     *
+     * GET /api/home
+     * Header: X-Channel: default|wap
+     *
+     * @return JsonResponse
+     */
+    public function index(): JsonResponse
+    {
+        $locale = core()->getRequestedLocaleCode();
+
+        // 通过 X-Channel header 区分 PC(default) / M(wap) 端
+        $channelCode = request()->header('X-Channel', 'default');
+        $channel = $this->channelRepository->findOneByField('code', $channelCode)
+            ?: core()->getCurrentChannel();
+
+        // 1. 获取主题自定义数据(banner、分类轮播、产品轮播等)
+        $customizations = $this->themeCustomizationRepository
+            ->orderBy('sort_order')
+            ->findWhere([
+                'status'     => 1,
+                'channel_id' => $channel->id,
+                'theme_code' => $channel->theme,
+            ]);
+
+        $sections = [];
+        foreach ($customizations as $item) {
+            $section = $this->formatSection($item, $locale);
+            if ($section !== null) {
+                $sections[] = $section;
+            }
+        }
+
+        // 2. 获取分类树(供导航使用)
+        $categories = $this->categoryRepository->getVisibleCategoryTree(
+            $channel->root_category_id
+        );
+
+        return response()->json([
+            'success' => true,
+            'data'    => [
+                'channel'    => $channel->code,
+                'sections'   => $sections,
+                'categories' => $this->formatCategoryTree($categories),
+            ],
+        ]);
+    }
+
+    /**
+     * 格式化单个主题区块
+     */
+    protected function formatSection($item, string $locale): ?array
+    {
+        $base = [
+            'id'        => $item->id,
+            'type'      => $item->type,
+            'name'      => $item->name,
+            'sort_order'=> (int) $item->sort_order,
+        ];
+
+        $options = $item->translate($locale)?->options ?? [];
+
+        switch ($item->type) {
+            case 'image_carousel':
+                return array_merge($base, [
+                    'images' => $options['images'] ?? [],
+                ]);
+
+            case 'static_content':
+                return array_merge($base, [
+                    'html' => $options['html'] ?? '',
+                    'css'  => $options['css'] ?? '',
+                ]);
+
+            case 'category_carousel':
+                return array_merge($base, [
+                    'images' => $options['images'] ?? [],
+                ]);
+
+            case 'product_carousel':
+                $filters = $options['filters'] ?? [];
+                return array_merge($base, [
+                    'title'    => $options['title'] ?? '',
+                    'products' => $this->getProductCarouselData($filters),
+                ]);
+
+            case 'services_content':
+                return array_merge($base, [
+                    'services' => $options['services'] ?? [],
+                ]);
+
+            default:
+                return null;
+        }
+    }
+
+    /**
+     * 获取产品轮播数据
+     */
+    protected function getProductCarouselData(array $filters): array
+    {
+        $params = [
+            'status'              => 1,
+            'visible_individually'=> 1,
+            'limit'               => $filters['limit'] ?? 12,
+            'sort'                => $filters['sort'] ?? 'name-asc',
+        ];
+
+        if (! empty($filters['new'])) {
+            $params['new'] = 1;
+        }
+
+        if (! empty($filters['featured'])) {
+            $params['featured'] = 1;
+        }
+
+        $products = $this->productRepository->getAll($params);
+
+        return $products->map(function ($product) {
+            $image = $product->images->first()
+                ?? $product->base_image;
+
+            return [
+                'id'               => $product->id,
+                'sku'              => $product->sku,
+                'name'             => $product->name,
+                'slug'             => $product->slug,
+                'type'             => $product->type,
+                'price'            => $product->getTypeInstance()->getMinimalPrice(),
+                'special_price'    => $product->special_price,
+                'image_url'        => $image->url ?? null,
+                'is_new'           => (bool) ($product->new ?? false),
+                'is_featured'      => (bool) ($product->featured ?? false),
+                'short_description'=> $product->short_description,
+            ];
+        })->values()->toArray();
+    }
+
+    /**
+     * 格式化分类树
+     */
+    protected function formatCategoryTree($categories): array
+    {
+        $result = [];
+
+        foreach ($categories as $category) {
+            $node = [
+                'id'          => $category->id,
+                'name'        => $category->name,
+                'slug'        => $category->slug,
+                'description' => $category->description,
+                'image_url'   => $category->image_url,
+                'banner_url'  => $category->banner_url,
+            ];
+
+            if ($category->children && $category->children->count() > 0) {
+                $node['children'] = $this->formatCategoryTree($category->children);
+            }
+
+            $result[] = $node;
+        }
+
+        return $result;
+    }
+}

+ 37 - 0
packages/Webkul/Shop/src/Http/Middleware/StorefrontKeyAuth.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Webkul\Shop\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+
+class StorefrontKeyAuth
+{
+    /**
+     * Handle an incoming request.
+     *
+     * 验证 X-STOREFRONT-KEY 请求头
+     */
+    public function handle(Request $request, Closure $next)
+    {
+        $storefrontKey = $request->header('X-STOREFRONT-KEY');
+
+        if (! $storefrontKey) {
+            return response()->json([
+                'success' => false,
+                'message' => 'X-STOREFRONT-KEY header is required',
+            ], 401);
+        }
+
+        $validKey = env('STOREFRONT_PLAYGROUND_KEY');
+
+        if ($storefrontKey !== $validKey) {
+            return response()->json([
+                'success' => false,
+                'message' => 'Invalid storefront key',
+            ], 403);
+        }
+
+        return $next($request);
+    }
+}

+ 2 - 0
packages/Webkul/Shop/src/Providers/ShopServiceProvider.php

@@ -11,6 +11,7 @@ use Webkul\Core\Http\Middleware\PreventRequestsDuringMaintenance;
 use Webkul\Shop\Http\Middleware\AuthenticateCustomer;
 use Webkul\Shop\Http\Middleware\AuthenticateCustomer;
 use Webkul\Shop\Http\Middleware\BearerTokenAuth;
 use Webkul\Shop\Http\Middleware\BearerTokenAuth;
 use Webkul\Shop\Http\Middleware\CacheResponse;
 use Webkul\Shop\Http\Middleware\CacheResponse;
+use Webkul\Shop\Http\Middleware\StorefrontKeyAuth;
 use Webkul\Shop\Http\Middleware\Currency;
 use Webkul\Shop\Http\Middleware\Currency;
 use Webkul\Shop\Http\Middleware\Locale;
 use Webkul\Shop\Http\Middleware\Locale;
 use Webkul\Shop\Http\Middleware\Theme;
 use Webkul\Shop\Http\Middleware\Theme;
@@ -42,6 +43,7 @@ class ShopServiceProvider extends ServiceProvider
         $router->aliasMiddleware('cache.response', CacheResponse::class);
         $router->aliasMiddleware('cache.response', CacheResponse::class);
         $router->aliasMiddleware('customer', AuthenticateCustomer::class);
         $router->aliasMiddleware('customer', AuthenticateCustomer::class);
         $router->aliasMiddleware('bearer.token', BearerTokenAuth::class);
         $router->aliasMiddleware('bearer.token', BearerTokenAuth::class);
+        $router->aliasMiddleware('storefront.key', StorefrontKeyAuth::class);
 
 
         Route::middleware(['web', 'shop', PreventRequestsDuringMaintenance::class])->group(__DIR__.'/../Routes/web.php');
         Route::middleware(['web', 'shop', PreventRequestsDuringMaintenance::class])->group(__DIR__.'/../Routes/web.php');
         Route::middleware(['web', 'shop', PreventRequestsDuringMaintenance::class])->group(__DIR__.'/../Routes/api.php');
         Route::middleware(['web', 'shop', PreventRequestsDuringMaintenance::class])->group(__DIR__.'/../Routes/api.php');

+ 11 - 2
packages/Webkul/Shop/src/Routes/api-token.php

@@ -3,17 +3,26 @@
 use Illuminate\Support\Facades\Route;
 use Illuminate\Support\Facades\Route;
 use Webkul\Shop\Http\Controllers\API\BearerAddressController;
 use Webkul\Shop\Http\Controllers\API\BearerAddressController;
 use Webkul\Shop\Http\Controllers\API\CouponController;
 use Webkul\Shop\Http\Controllers\API\CouponController;
+use Webkul\Shop\Http\Controllers\API\HomeController;
 use Webkul\Shop\Http\Controllers\API\OrderController;
 use Webkul\Shop\Http\Controllers\API\OrderController;
 
 
 /**
 /**
- * Bearer Token 认证的 API 路由
+ * Token / Storefront Key 认证的 API 路由
  *
  *
- * 认证方式: Authorization: Bearer <sanctum_token>
  * 不受 web 中间件组影响(无 Session、无 CSRF、无 Cookie 加密)
  * 不受 web 中间件组影响(无 Session、无 CSRF、无 Cookie 加密)
  */
  */
 
 
 Route::group(['prefix' => 'api'], function () {
 Route::group(['prefix' => 'api'], function () {
 
 
+    /**
+     * 首页数据接口(X-STOREFRONT-KEY 认证,无需登录)
+     */
+    Route::controller(HomeController::class)
+        ->middleware('storefront.key')
+        ->group(function () {
+            Route::get('home', 'index')->name('shop.api.home.index');
+        });
+
     /** 客户地址管理(Bearer Token 认证) */
     /** 客户地址管理(Bearer Token 认证) */
     Route::controller(BearerAddressController::class)
     Route::controller(BearerAddressController::class)
         ->middleware('bearer.token')
         ->middleware('bearer.token')

+ 13 - 13
packages/Webkul/Theme/src/Repositories/ThemeCustomizationRepository.php

@@ -3,9 +3,8 @@
 namespace Webkul\Theme\Repositories;
 namespace Webkul\Theme\Repositories;
 
 
 use Illuminate\Http\UploadedFile;
 use Illuminate\Http\UploadedFile;
-use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
 use Illuminate\Support\Str;
-use Intervention\Image\ImageManager;
+use Longyi\ImageUpload\Services\ImageUploadService;
 use Stevebauman\Purify\Facades\Purify;
 use Stevebauman\Purify\Facades\Purify;
 use Webkul\Core\Eloquent\Repository;
 use Webkul\Core\Eloquent\Repository;
 use Webkul\Theme\Contracts\ThemeCustomization;
 use Webkul\Theme\Contracts\ThemeCustomization;
@@ -58,9 +57,6 @@ class ThemeCustomizationRepository extends Repository
     /**
     /**
      * Mass update the status of themes in the repository.
      * Mass update the status of themes in the repository.
      *
      *
-     * This method updates multiple records in the database based on the provided
-     * theme IDs.
-     *
      * @param  int  $themeIds
      * @param  int  $themeIds
      * @return int The number of records updated.
      * @return int The number of records updated.
      */
      */
@@ -77,10 +73,17 @@ class ThemeCustomizationRepository extends Repository
     public function uploadImage(array $data, ThemeCustomization $theme)
     public function uploadImage(array $data, ThemeCustomization $theme)
     {
     {
         $locale = core()->getRequestedLocaleCode();
         $locale = core()->getRequestedLocaleCode();
+        $imageUploadService = app(ImageUploadService::class);
 
 
         if (isset($data[$locale]['deleted_sliders'])) {
         if (isset($data[$locale]['deleted_sliders'])) {
             foreach ($data[$locale]['deleted_sliders'] as $slider) {
             foreach ($data[$locale]['deleted_sliders'] as $slider) {
-                Storage::delete(str_replace('storage/', '', $slider['image']));
+                $deletePath = str_replace('storage/', '', $slider['image']);
+                $deletePath = str_replace(rtrim(env('AWS_URL', ''), '/') . '/', '', $deletePath);
+                try {
+                    $imageUploadService->delete(ltrim($deletePath, '/'));
+                } catch (\Exception $e) {
+                    // S3 删除失败不阻塞流程
+                }
             }
             }
         }
         }
 
 
@@ -99,11 +102,8 @@ class ThemeCustomizationRepository extends Repository
                 ];
                 ];
             } elseif ($image['image'] instanceof UploadedFile) {
             } elseif ($image['image'] instanceof UploadedFile) {
                 try {
                 try {
-                    $manager = new ImageManager;
-
-                    $path = 'theme/'.$theme->id.'/'.Str::random(40).'.webp';
-
-                    Storage::put($path, $manager->make($image['image'])->encode('webp'));
+                    $directory = 'uploads/theme/'  . $theme->id;
+                    $result = $imageUploadService->upload($image['image'], $directory);
                 } catch (\Exception $e) {
                 } catch (\Exception $e) {
                     session()->flash('error', $e->getMessage());
                     session()->flash('error', $e->getMessage());
 
 
@@ -111,11 +111,11 @@ class ThemeCustomizationRepository extends Repository
                 }
                 }
 
 
                 if (($data['type'] ?? '') == 'static_content') {
                 if (($data['type'] ?? '') == 'static_content') {
-                    return Storage::url($path);
+                    return $result['url'];
                 }
                 }
 
 
                 $options['images'][] = [
                 $options['images'][] = [
-                    'image' => 'storage/'.$path,
+                    'image' => $result['url'],
                     'link'  => $image['link'],
                     'link'  => $image['link'],
                     'title' => $image['title'],
                     'title' => $image['title'],
                 ];
                 ];