bianjunhui 2 дней назад
Родитель
Сommit
ddce035f76

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

@@ -107,7 +107,7 @@
                     <!-- Hidden Input -->
                     <input
                         type="hidden"
-                        :name="'{{ $currentLocale->code }}[options][filters][' + filter.key +']'"
+                        :name="'{{ $currentLocale->code }}[options][filters][' + filter.key + '][]'"
                         :value="filter.value"
                     > 
                 
@@ -325,18 +325,25 @@
             created() {
                 if (this.options === null) {
                     this.options = { filters: {} };
-                }   
+                }
 
                 if (! 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]
-                    }));
+                // 把对象形式(保存后)还原成数组形式(用于渲染)
+                // 也兼容老格式(保存的是单值字符串)
+                const raw = this.options.filters;
+                const reserved = ['sort', 'limit', 'title'];
+                const list = [];
+
+                Object.keys(raw).forEach(key => {
+                    if (reserved.includes(key)) return;
+                    const vals = Array.isArray(raw[key]) ? raw[key] : [raw[key]];
+                    vals.forEach(v => list.push({ key: key, value: v }));
+                });
+
+                this.options.filters = list;
             },
             
             methods: {

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

@@ -0,0 +1,271 @@
+<?php
+
+namespace Webkul\Shop\Http\Controllers\API;
+
+use Illuminate\Http\JsonResponse;
+use Webkul\Category\Repositories\CategoryRepository;
+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,
+    ) {}
+
+    /**
+     * 获取首页完整数据
+     *
+     * GET /api/home
+     *
+     * @return JsonResponse
+     */
+    public function index(): JsonResponse
+    {
+        $channel  = core()->getCurrentChannel();
+        $locale   = core()->getRequestedLocaleCode();
+
+        // 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'    => [
+                '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':
+                $filters = $options['filters'] ?? [];
+                return array_merge($base, [
+                    'title'      => $options['title'] ?? '',
+                    'categories' => $this->getCategoryCarouselData($filters),
+                ]);
+
+            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 getCategoryCarouselData(array $filters): array
+    {
+        // 1. 收集所有 parent_id(兼容字符串单值、字符串数组两种格式)
+        $parentIds = $this->extractParentIds($filters);
+
+        $sort  = $filters['sort'] ?? 'asc';
+        $limit = (int) ($filters['limit'] ?? 10);
+
+        // 2. 无 parent_id:按 limit 查询
+        if (empty($parentIds)) {
+            $categories = $this->categoryRepository->getAll([
+                'status' => 1,
+                'sort'   => $sort,
+                'limit'  => $limit,
+                'locale' => core()->getRequestedLocaleCode(),
+            ]);
+
+            return $this->formatCategories($categories);
+        }
+
+        // 3. 多个 parent_id:按每个 parent 平均分配 limit 后合并去重
+        $perParent = max((int) ceil($limit / count($parentIds)), 1);
+        $result    = [];
+        $seenIds   = [];
+
+        foreach ($parentIds as $parentId) {
+            $categories = $this->categoryRepository->getAll([
+                'status'    => 1,
+                'sort'      => $sort,
+                'limit'     => $perParent,
+                'parent_id' => $parentId,
+                'locale'    => core()->getRequestedLocaleCode(),
+            ]);
+
+            foreach ($categories as $cat) {
+                if (isset($seenIds[$cat->id])) {
+                    continue;
+                }
+                $seenIds[$cat->id] = true;
+                $result[] = $cat;
+                if (count($result) >= $limit) {
+                    break 2;
+                }
+            }
+        }
+
+        return $this->formatCategories(collect($result));
+    }
+
+    /**
+     * 从 filters 中提取所有 parent_id(支持单值或多值数组)
+     */
+    protected function extractParentIds(array $filters): array
+    {
+        if (empty($filters['parent_id'])) {
+            return [];
+        }
+
+        $values = $filters['parent_id'];
+
+        // 已是数组
+        if (is_array($values)) {
+            return array_values(array_filter(array_map('intval', $values), fn ($v) => $v > 0));
+        }
+
+        // 单值字符串,去掉多余空白
+        return array_values(array_filter(array_map('intval', explode(',', (string) $values)),
+            fn ($v) => $v > 0));
+    }
+
+    /**
+     * 格式化分类集合为数组
+     */
+    protected function formatCategories($categories): array
+    {
+        return $categories->map(fn ($cat) => [
+            'id'            => $cat->id,
+            'name'          => $cat->name,
+            'slug'          => $cat->slug,
+            'description'   => $cat->description,
+            'image_url'     => $cat->image_url,
+            'banner_url'    => $cat->banner_url,
+            'product_count' => (int) ($cat->products_count ?? 0),
+        ])->values()->toArray();
+    }
+
+    /**
+     * 获取产品轮播数据
+     */
+    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\BearerTokenAuth;
 use Webkul\Shop\Http\Middleware\CacheResponse;
+use Webkul\Shop\Http\Middleware\StorefrontKeyAuth;
 use Webkul\Shop\Http\Middleware\Currency;
 use Webkul\Shop\Http\Middleware\Locale;
 use Webkul\Shop\Http\Middleware\Theme;
@@ -42,6 +43,7 @@ class ShopServiceProvider extends ServiceProvider
         $router->aliasMiddleware('cache.response', CacheResponse::class);
         $router->aliasMiddleware('customer', AuthenticateCustomer::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/api.php');

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

@@ -3,17 +3,26 @@
 use Illuminate\Support\Facades\Route;
 use Webkul\Shop\Http\Controllers\API\BearerAddressController;
 use Webkul\Shop\Http\Controllers\API\CouponController;
+use Webkul\Shop\Http\Controllers\API\HomeController;
 use Webkul\Shop\Http\Controllers\API\OrderController;
 
 /**
- * Bearer Token 认证的 API 路由
+ * Token / Storefront Key 认证的 API 路由
  *
- * 认证方式: Authorization: Bearer <sanctum_token>
  * 不受 web 中间件组影响(无 Session、无 CSRF、无 Cookie 加密)
  */
 
 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 认证) */
     Route::controller(BearerAddressController::class)
         ->middleware('bearer.token')