Преглед изворни кода

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

chengwl пре 6 дана
родитељ
комит
200ffe6676
29 измењених фајлова са 2029 додато и 25 уклоњено
  1. 62 0
      packages/Longyi/Gift/src/Console/Commands/ExpireGiftCards.php
  2. 6 5
      packages/Longyi/Gift/src/DataGrids/GiftCards/GiftCardsDataGrid.php
  3. 23 3
      packages/Longyi/Gift/src/Http/Controllers/Admin/GiftController.php
  4. 15 2
      packages/Longyi/Gift/src/Http/Controllers/Shop/GiftController.php
  5. 56 1
      packages/Longyi/Gift/src/Listeners/GiftHandler.php
  6. 49 8
      packages/Longyi/Gift/src/Models/GiftCards.php
  7. 16 0
      packages/Longyi/Gift/src/Providers/GiftServiceProvider.php
  8. 24 0
      packages/Longyi/Gift/src/Resources/lang/en/app.php
  9. 24 0
      packages/Longyi/Gift/src/Resources/lang/zh_CN/app.php
  10. 337 2
      packages/Longyi/Gift/src/Resources/views/admin/index.blade.php
  11. 1 1
      packages/Longyi/Gift/src/Resources/views/sales/invoices/view.blade.php
  12. 2 0
      packages/Longyi/Gift/src/Routes/admin-routes.php
  13. 64 0
      packages/Longyi/Gift/src/Services/GiftCardExpiryService.php
  14. 4 0
      packages/Longyi/Gift/src/Services/GiftCardService.php
  15. 147 0
      packages/Longyi/Gift/src/Services/GiftCardStatisticsService.php
  16. 6 0
      packages/Longyi/Member/src/Config/acl.php
  17. 1 0
      packages/Longyi/Member/src/DataGrids/Member/MemberDataGrid.php
  18. 90 0
      packages/Longyi/Member/src/Http/Controllers/Admin/MemberController.php
  19. 10 0
      packages/Longyi/Member/src/Imports/MemberPlusSpreadsheet.php
  20. 50 0
      packages/Longyi/Member/src/Resources/lang/en/app.php
  21. 50 0
      packages/Longyi/Member/src/Resources/lang/zh_CN/app.php
  22. 445 0
      packages/Longyi/Member/src/Resources/views/admin/index.blade.php
  23. 3 1
      packages/Longyi/Member/src/Routes/admin-routes.php
  24. 9 0
      packages/Longyi/Member/src/Services/MemberPlusImportException.php
  25. 358 0
      packages/Longyi/Member/src/Services/MemberPlusImportService.php
  26. 156 0
      packages/Longyi/Member/src/Services/MemberPlusStatisticsService.php
  27. 7 2
      packages/Webkul/Admin/src/Resources/views/settings/exchange-rates/index.blade.php
  28. 4 0
      packages/Webkul/Shop/src/Listeners/ThemeCacheCleaner.php
  29. 10 0
      packages/Webkul/Theme/src/Models/ThemeCustomization.php

+ 62 - 0
packages/Longyi/Gift/src/Console/Commands/ExpireGiftCards.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace Longyi\Gift\Console\Commands;
+
+use Illuminate\Console\Command;
+use Longyi\Gift\Services\GiftCardExpiryService;
+
+class ExpireGiftCards extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'gift-cards:expire
+                            {--dry-run : 只统计已过期的未使用礼品卡数量,不写库}
+                            {--batch=500 : 每批处理条数}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '把已过期的未使用礼品卡状态改为「已过期」';
+
+    public function __construct(protected GiftCardExpiryService $expiryService)
+    {
+        parent::__construct();
+    }
+
+    public function handle(): int
+    {
+        $startedAt = now();
+        $dryRun = (bool) $this->option('dry-run');
+
+        $this->info($dryRun
+            ? '统计已过期的未使用礼品卡...'
+            : '开始处理过期礼品卡...');
+
+        try {
+            $count = $this->expiryService->expire(
+                null,
+                $dryRun,
+                (int) $this->option('batch')
+            );
+        } catch (\Throwable $e) {
+            $this->error('处理失败:'.$e->getMessage());
+            report($e);
+
+            return self::FAILURE;
+        }
+
+        $this->info(sprintf(
+            '%s %d 张礼品卡,耗时 %d 秒。',
+            $dryRun ? '共发现' : '共更新',
+            $count,
+            $startedAt->diffInSeconds(now())
+        ));
+
+        return self::SUCCESS;
+    }
+}

+ 6 - 5
packages/Longyi/Gift/src/DataGrids/GiftCards/GiftCardsDataGrid.php

@@ -4,6 +4,7 @@ namespace Longyi\Gift\DataGrids\GiftCards;
 
 use Illuminate\Support\Facades\DB;
 use Webkul\DataGrid\DataGrid;
+use Longyi\Gift\Models\GiftCards;
 use Longyi\Gift\Models\GiftCardUsageLog;
 
 class GiftCardsDataGrid extends DataGrid
@@ -146,11 +147,11 @@ class GiftCardsDataGrid extends DataGrid
             'filterable' => true,
             'sortable'   => true,
             'closure'    => function ($row) {
-                if ($row->giftcard_status == 1) {
-                    return '<span class="badge badge-md badge-success">未使用</span>';
-                } else {
-                    return '<span class="badge badge-md badge-warning">已使用</span>';
-                }
+                return match ((int) $row->giftcard_status) {
+                    GiftCards::STATUS_USED => '<span class="badge badge-md badge-warning">'.trans('gift::app.admin.datagrid.used').'</span>',
+                    GiftCards::STATUS_EXPIRED => '<span class="badge badge-md badge-danger">'.trans('gift::app.admin.datagrid.expired').'</span>',
+                    default => '<span class="badge badge-md badge-success">'.trans('gift::app.admin.datagrid.unused').'</span>',
+                };
             },
         ]);
         $this->addColumn([

+ 23 - 3
packages/Longyi/Gift/src/Http/Controllers/Admin/GiftController.php

@@ -3,11 +3,13 @@
 namespace Longyi\Gift\Http\Controllers\Admin;
 
 use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
 use Illuminate\View\View;
 use Longyi\Gift\Models\GiftCards;
 use Webkul\Admin\Http\Controllers\Controller;
 use Longyi\Gift\DataGrids\GiftCards\GiftCardsDataGrid;
 use Longyi\Gift\Repositories\GiftCardsRepository;
+use Longyi\Gift\Services\GiftCardStatisticsService;
 use Illuminate\Support\Str;
 use Webkul\Customer\Models\Customer;
 
@@ -21,7 +23,8 @@ class GiftController extends Controller
      * @return void
      */
     public function __construct(
-        protected GiftCardsRepository $giftCardsRepository
+        protected GiftCardsRepository $giftCardsRepository,
+        protected GiftCardStatisticsService $statisticsService
     ) {
     }
     /**
@@ -36,6 +39,23 @@ class GiftController extends Controller
         return view('gift::admin.index');
     }
 
+    /**
+     * 礼品卡统计:新增 / 过期。
+     */
+    public function statistics(Request $request): JsonResponse
+    {
+        $request->validate([
+            'start' => ['nullable', 'date'],
+            'end'   => ['nullable', 'date'],
+        ]);
+
+        $end = $request->date('end') ?? now();
+
+        $start = $request->date('start') ?? $end->copy()->subDays(29);
+
+        return new JsonResponse($this->statisticsService->get($start, $end));
+    }
+
     /**
      * Store a newly created resource in storage.
      */
@@ -46,7 +66,7 @@ class GiftController extends Controller
             'customer_email'   => 'nullable|email',
             'customer_id'      => 'nullable|integer|exists:customers,id',
             'expirationdate'   => 'required|date_format:Y-m-d H:i:s',
-            'giftcard_status'  => 'required|in:1,2',
+            'giftcard_status'  => 'required|in:1,2,3',
         ]);
 
         // 如果填写了邮箱,必须验证通过(customer_id 不能为空)
@@ -110,7 +130,7 @@ class GiftController extends Controller
         $data = request()->validate([
             'id'                => 'required|integer',
             'expirationdate'    => 'required|date_format:Y-m-d H:i:s',
-            'giftcard_status'   => 'required|in:1,2',
+            'giftcard_status'   => 'required|in:1,2,3',
         ]);
 
         $giftCard = $this->giftCardsRepository->find($data['id']);

+ 15 - 2
packages/Longyi/Gift/src/Http/Controllers/Shop/GiftController.php

@@ -32,9 +32,9 @@ class GiftController extends Controller
     public function lists(): JsonResponse
     {
         $customerId = auth()->user()->id;
-        // 获取用户拥有的所有礼品卡
+        // 获取用户拥有的所有礼品卡(已过期的也算领过,避免重复兑换同一渠道)
         $userGiftCards = GiftCards::where('customer_id', $customerId)
-            ->where('giftcard_status', 1)
+            ->whereIn('giftcard_status', GiftCards::CLAIMED_STATUSES)
             ->get()
             ->keyBy('channel');
         $gifts = $this->giftCardsModel->giftcards();
@@ -60,6 +60,15 @@ class GiftController extends Controller
         }
         $customerId = auth()->user()->id;
         try {
+            // 该渠道已经领过(含已过期)的卡,不允许再拿积分重复兑换
+            $gift = $this->giftCardsModel->giftcards()[$id] ?? null;
+            $alreadyClaimed = $gift && GiftCards::where('customer_id', $customerId)
+                ->where('channel', $gift['channel'])
+                ->whereIn('giftcard_status', GiftCards::CLAIMED_STATUSES)
+                ->exists();
+            if ($alreadyClaimed) {
+                return ApiResponse::error('Gift card already claimed.');
+            }
             $this->giftCardsModel->add($id, $customerId);
         } catch (\Exception $e) {
             return ApiResponse::error($e->getMessage());
@@ -116,6 +125,10 @@ class GiftController extends Controller
            if (!$giftCard) {
                return ApiResponse::error('giftcard not found.');
            }
+           // 已过期的礼品卡不能再用
+           if ($giftCard->isExpired()) {
+               return ApiResponse::error('Gift card has expired.');
+           }
            $remainingGiftcardAmount = core()->convertPrice($giftCard->remaining_giftcard_amount);
            if ($remainingGiftcardAmount <= 0) {
                return ApiResponse::error('Gift card already used.');

+ 56 - 1
packages/Longyi/Gift/src/Listeners/GiftHandler.php

@@ -3,6 +3,7 @@
 namespace Longyi\Gift\Listeners;
 
 use Webkul\Paypal\Payment\SmartButton;
+use Illuminate\Support\Facades\Log;
 use Webkul\Sales\Repositories\OrderTransactionRepository;
 use Longyi\Gift\Models\GiftCards;
 
@@ -31,7 +32,9 @@ class GiftHandler
         }
         // 获取礼品卡对象
         $giftCard = GiftCards::where('giftcard_number', $cart->giftcard_number)->first();
-        if (!$giftCard) {
+        // 卡不存在 / 已过期 / 没余额 / 不属于当前客户:摘掉购物车上的卡,不做抵扣
+        if (!$giftCard || !$this->isUsable($cart, $giftCard)) {
+            $this->detachGiftCard($cart);
             return;
         }
         $remainingAmountInCurrentCurrency = core()->convertPrice($giftCard->remaining_giftcard_amount);
@@ -47,4 +50,56 @@ class GiftHandler
         $cart->base_grand_total = max(0, round($cart->base_grand_total - $giftCardAmountInBaseCurrency, 2));
         $cart->save();
     }
+
+    /**
+     * 礼品卡当前是否可以抵扣:未过期、还有余额、属于购物车对应的客户。
+     *
+     * @param  \Webkul\Checkout\Models\Cart  $cart
+     * @param  \Longyi\Gift\Models\GiftCards  $giftCard
+     * @return bool
+     */
+    protected function isUsable($cart, $giftCard): bool
+    {
+        if ($giftCard->isExpired()) {
+            return false;
+        }
+
+        if ((float) $giftCard->remaining_giftcard_amount <= 0) {
+            return false;
+        }
+
+        // 两边都有客户编号且对不上,才判定为不是本人的卡(购物车没绑客户时不拦)
+        $cartCustomerId = (int) ($cart->customer_id ?? 0);
+        $cardCustomerId = (int) ($giftCard->customer_id ?? 0);
+
+        if ($cartCustomerId && $cardCustomerId && $cartCustomerId !== $cardCustomerId) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * 把不可用的礼品卡从购物车上摘掉。
+     *
+     * 注意:此时 `grand_total` 是 collectTotals() 刚从商品/运费重算出来的全价,
+     * 里面并没有减过这张卡,所以这里只清空礼品卡字段,**不能再回补金额**。
+     *
+     * @param  \Webkul\Checkout\Models\Cart  $cart
+     * @return void
+     */
+    protected function detachGiftCard($cart): void
+    {
+        Log::info('Gift card detached from cart: '.$cart->giftcard_number, [
+            'cart_id'         => $cart->id,
+            'giftcard_number' => $cart->giftcard_number,
+            'customer_id'     => $cart->customer_id,
+        ]);
+
+        $cart->giftcard_number = null;
+        $cart->giftcard_amount = null;
+        $cart->base_giftcard_amount = null;
+
+        $cart->save();
+    }
 }

+ 49 - 8
packages/Longyi/Gift/src/Models/GiftCards.php

@@ -17,6 +17,29 @@ class GiftCards extends Model implements GiftCardsContract
 {
     use HasFactory;
 
+    /**
+     * 未使用(可用)。
+     */
+    public const STATUS_UNUSED = 1;
+
+    /**
+     * 已使用(余额已用完)。
+     */
+    public const STATUS_USED = 2;
+
+    /**
+     * 已过期(超过 expirationdate,由 gift-cards:expire 定时任务写入)。
+     */
+    public const STATUS_EXPIRED = 3;
+
+    /**
+     * 「已经领过」的状态:未使用 + 已过期(已用完的不算)。
+     *
+     * 礼品卡过期后状态会从「未使用」变成「已过期」,但业务上依然算领过,
+     * 不能再拿积分重复兑换同一渠道的礼品卡 —— 所以前台列表和兑换接口统一用这个范围。
+     */
+    public const CLAIMED_STATUSES = [self::STATUS_UNUSED, self::STATUS_EXPIRED];
+
     protected $table = 'gift_cards';
 
     protected $fillable = [
@@ -104,13 +127,15 @@ class GiftCards extends Model implements GiftCardsContract
         if (empty($giftcardAmount)) {
             throw new \Exception('giftcardAmount is empty');
         }
-        $data = GiftCards::where('customer_id', $customerId)
-            ->where('channel', $channel)
-            ->count();
-        if ($num != 0) {
-            $giftNum =count($data);
-            if($giftNum <= $num){
-                throw new \Exception('gift num is not enough');
+        // $num > 0 表示该渠道最多允许「已领过」(未使用 + 已过期)这么多张,超出就报错
+        if ($num > 0) {
+            $claimedNum = GiftCards::where('customer_id', $customerId)
+                ->where('channel', $channel)
+                ->whereIn('giftcard_status', self::CLAIMED_STATUSES)
+                ->count();
+
+            if ($claimedNum >= $num) {
+                throw new \Exception("Gift card limit reached for channel {$channel} (max: {$num}).");
             }
         }
         $expirationDate = self::calculateExpirationDate($expirationdate);
@@ -121,7 +146,7 @@ class GiftCards extends Model implements GiftCardsContract
             'remaining_giftcard_amount' => $giftcardAmount,
             'customer_id' => $customerId,
             'expirationdate' => $expirationDate,
-            'giftcard_status' => 1,
+            'giftcard_status' => self::STATUS_UNUSED,
             'channel' => $channel,
             'notes' => $notes
         ]);
@@ -156,6 +181,22 @@ class GiftCards extends Model implements GiftCardsContract
 
         throw new \Exception('Invalid expiration date type. Must be integer (days), string (Y-m-d), or Carbon instance.');
     }
+
+    /**
+     * 这张礼品卡是否已过期。
+     *
+     * 状态已被定时任务标成「已过期」,或者过期时间已经过去,都算过期;
+     * expirationdate 为空的卡视为永久有效。
+     */
+    public function isExpired(): bool
+    {
+        if ((int) $this->giftcard_status === self::STATUS_EXPIRED) {
+            return true;
+        }
+
+        return $this->expirationdate !== null && $this->expirationdate->isPast();
+    }
+
     public static function generateGiftCardNumber()
     {
         do {

+ 16 - 0
packages/Longyi/Gift/src/Providers/GiftServiceProvider.php

@@ -5,8 +5,10 @@ namespace Longyi\Gift\Providers;
 use Longyi\Gift\Repositories\CustomInvoiceRepository;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\Facades\Event;
+use Illuminate\Console\Scheduling\Schedule;
 use Longyi\Gift\Providers\EventServiceProvider;
 use Longyi\Gift\Repositories\GiftCardsRepository;
+use Longyi\Gift\Console\Commands\ExpireGiftCards;
 use Webkul\Sales\Repositories\InvoiceRepository;
 use Webkul\Sales\Repositories\OrderRepository;
 use Longyi\Gift\Repositories\CustomOrderRepository;
@@ -21,6 +23,12 @@ class GiftServiceProvider extends ServiceProvider
         $this->app->bind(InvoiceRepository::class, CustomInvoiceRepository::class);
         $this->app->bind(OrderRepository::class, CustomOrderRepository::class);
         $this->registerConfig();
+
+        if ($this->app->runningInConsole()) {
+            $this->commands([
+                ExpireGiftCards::class,
+            ]);
+        }
     }
 
     /**
@@ -43,6 +51,14 @@ class GiftServiceProvider extends ServiceProvider
         Event::listen('bagisto.admin.layout.head', function($viewRenderEventManager) {
             $viewRenderEventManager->addTemplate('gift::admin.layouts.style');
         });
+
+        // 每小时把已过期的未使用礼品卡标记为「已过期」
+        $this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
+            $schedule->command('gift-cards:expire')
+                ->hourly()
+                ->withoutOverlapping();
+        });
+
         $this->app->register(EventServiceProvider::class);
     }
 

+ 24 - 0
packages/Longyi/Gift/src/Resources/lang/en/app.php

@@ -17,6 +17,9 @@ return [
             'customer-id'       => 'Customer ID',
             'expiration-date'   => 'Expiration Date',
             'status'            => 'Status',
+            'unused'            => 'Unused',
+            'used'              => 'Used',
+            'expired'           => 'Expired',
             'notes'             => 'Notes',
             'created-at'        => 'Created At',
             'edit'              => 'Edit',
@@ -27,6 +30,26 @@ return [
             'mass-delete-error' => 'Mass delete failed',
         ],
 
+        'stats' => [
+            'title'          => 'Gift Card Statistics',
+            'hint'           => 'New: based on the gift card creation date. Expired: based on the gift card expiry date (used or not). Long ranges are grouped by month.',
+            'show'           => 'Show statistics',
+            'hide'           => 'Collapse',
+            'start-date'     => 'Start date',
+            'end-date'       => 'End date',
+            'range-7'        => 'Last 7 days',
+            'range-30'       => 'Last 30 days',
+            'range-90'       => 'Last 90 days',
+            'range-365'      => 'Last 1 year',
+            'available'      => 'Available',
+            'used'           => 'Used',
+            'new-total'      => 'New in range',
+            'expired'        => 'Expired in range',
+            'series-new'     => 'New gift cards',
+            'series-expired' => 'Expired gift cards',
+            'empty'          => 'No data in the selected range.',
+        ],
+
         'create' => [
             'title'                      => 'Create Gift Card',
             'save-btn'                   => 'Save',
@@ -41,6 +64,7 @@ return [
             'giftcard-status'            => 'Status',
             'unused'                     => 'Unused',
             'used'                       => 'Used',
+            'expired'                    => 'Expired',
             'validating'                 => 'Validating...',
             'validation-failed'          => 'Validation failed, please try again',
             'create-success'             => 'Gift card created successfully',

+ 24 - 0
packages/Longyi/Gift/src/Resources/lang/zh_CN/app.php

@@ -17,6 +17,9 @@ return [
             'customer-id'       => '客户 ID',
             'expiration-date'   => '过期日期',
             'status'            => '状态',
+            'unused'            => '未使用',
+            'used'              => '已使用',
+            'expired'           => '已过期',
             'notes'             => '备注',
             'created-at'        => '创建时间',
             'edit'              => '编辑',
@@ -27,6 +30,26 @@ return [
             'mass-delete-error' => '批量删除失败',
         ],
 
+        'stats' => [
+            'title'          => '礼品卡统计',
+            'hint'           => '新增:按礼品卡的创建时间统计;过期:按礼品卡的过期时间统计(不区分是否已使用)。区间过长时自动按月汇总。',
+            'show'           => '查看统计',
+            'hide'           => '收起',
+            'start-date'     => '开始日期',
+            'end-date'       => '结束日期',
+            'range-7'        => '近 7 天',
+            'range-30'       => '近 30 天',
+            'range-90'       => '近 90 天',
+            'range-365'      => '近 1 年',
+            'available'      => '当前可用',
+            'used'           => '已使用',
+            'new-total'      => '区间新增',
+            'expired'        => '区间过期',
+            'series-new'     => '新增礼品卡',
+            'series-expired' => '过期礼品卡',
+            'empty'          => '所选区间没有数据。',
+        ],
+
         'create' => [
             'title'                      => '创建礼品卡',
             'save-btn'                   => '保存',
@@ -41,6 +64,7 @@ return [
             'giftcard-status'            => '状态',
             'unused'                     => '未使用',
             'used'                       => '已使用',
+            'expired'                    => '已过期',
             'validating'                 => '正在验证...',
             'validation-failed'          => '验证失败,请稍后重试',
             'create-success'             => '礼品卡创建成功',

+ 337 - 2
packages/Longyi/Gift/src/Resources/views/admin/index.blade.php

@@ -4,6 +4,17 @@
     </x-slot>
 
     <v-gift-cards>
+        <!-- Statistics Shimmer -->
+        <div class="box-shadow rounded bg-white dark:bg-gray-900">
+            <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
+                <p class="text-base font-semibold text-gray-800 dark:text-white">
+                    @lang('gift::app.admin.stats.title')
+                </p>
+
+                <div class="shimmer h-[39px] w-[140px] rounded-md"></div>
+            </div>
+        </div>
+
         <!-- DataGrid Shimmer -->
         <x-admin::shimmer.datagrid />
     </v-gift-cards>
@@ -32,6 +43,9 @@
                     </div>
                 </div>
 
+                <!-- Gift Card Statistics(默认收起,点按钮才异步加载) -->
+                <v-gift-card-statistics></v-gift-card-statistics>
+
                 <x-admin::datagrid
                     :src="route('admin.gift.index')"
                     ref="datagrid"
@@ -245,6 +259,7 @@
                                     >
                                         <option value="1">@lang('gift::app.admin.create.unused')</option>
                                         <option value="2">@lang('gift::app.admin.create.used')</option>
+                                        <option value="3">@lang('gift::app.admin.create.expired')</option>
                                     </x-admin::form.control-group.control>
                                     <x-admin::form.control-group.error control-name="giftcard_status" />
                                 </x-admin::form.control-group>
@@ -278,7 +293,10 @@
                 data() {
                     return {
                         selectedGift: 0,
-                        selectedGiftData: {},
+                        // giftcard_status 默认未使用(1)
+                        selectedGiftData: {
+                            giftcard_status: '1',
+                        },
                         isLoading: false,
                         emailValidationMessage: '',
                         emailDebounceTimer: null,
@@ -409,11 +427,328 @@
                     },
 
                     resetForm() {
-                        this.selectedGiftData = {};
+                        // 新建时状态默认未使用(1)
+                        this.selectedGiftData = {
+                            giftcard_status: '1',
+                        };
                         this.emailValidationMessage = '';
                     }
                 }
             })
         </script>
     @endPushOnce
+
+    @pushOnce('scripts')
+        <script
+            type="module"
+            src="{{ bagisto_asset('js/chart.js') }}"
+        >
+        </script>
+
+        <script
+            type="text/x-template"
+            id="v-gift-card-statistics-template"
+        >
+            <div class="mt-3.5 box-shadow rounded bg-white dark:bg-gray-900">
+                <!-- 标题 + 展开按钮 -->
+                <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
+                    <div>
+                        <p class="text-base font-semibold text-gray-800 dark:text-white">
+                            @lang('gift::app.admin.stats.title')
+                        </p>
+
+                        <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                            @lang('gift::app.admin.stats.hint')
+                        </p>
+                    </div>
+
+                    <button
+                        type="button"
+                        class="secondary-button"
+                        @click="toggle()"
+                    >
+                        <span class="flex items-center gap-x-2.5">
+                            <span v-if="isOpen">@lang('gift::app.admin.stats.hide')</span>
+
+                            <span v-else>@lang('gift::app.admin.stats.show')</span>
+
+                            <span
+                                class="text-2xl text-gray-400"
+                                :class="isOpen ? 'icon-sort-up' : 'icon-sort-down'"
+                            ></span>
+                        </span>
+                    </button>
+                </div>
+
+                <!-- 统计内容 -->
+                <div
+                    v-if="isOpen"
+                    class="border-t px-4 py-4 dark:border-gray-800"
+                >
+                    <!-- 时间区间 -->
+                    <div class="flex flex-wrap items-end gap-x-2.5 gap-y-2">
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="applyQuickRange(7)"
+                        >
+                            @lang('gift::app.admin.stats.range-7')
+                        </button>
+
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="applyQuickRange(30)"
+                        >
+                            @lang('gift::app.admin.stats.range-30')
+                        </button>
+
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="applyQuickRange(90)"
+                        >
+                            @lang('gift::app.admin.stats.range-90')
+                        </button>
+
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="applyQuickRange(365)"
+                        >
+                            @lang('gift::app.admin.stats.range-365')
+                        </button>
+
+                        <x-admin::flat-picker.date class="!w-[140px]" ::allow-input="false">
+                            <input
+                                class="flex min-h-[39px] w-full rounded-md border px-3 py-2 text-sm text-gray-600 transition-all hover:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400"
+                                v-model="filters.start"
+                                placeholder="@lang('gift::app.admin.stats.start-date')"
+                            />
+                        </x-admin::flat-picker.date>
+
+                        <x-admin::flat-picker.date class="!w-[140px]" ::allow-input="false">
+                            <input
+                                class="flex min-h-[39px] w-full rounded-md border px-3 py-2 text-sm text-gray-600 transition-all hover:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400"
+                                v-model="filters.end"
+                                placeholder="@lang('gift::app.admin.stats.end-date')"
+                            />
+                        </x-admin::flat-picker.date>
+                    </div>
+
+                    <!-- 概览 -->
+                    <div class="mt-4 grid grid-cols-2 gap-4">
+                        <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
+                            <p class="text-xs text-gray-500 dark:text-gray-400">
+                                @lang('gift::app.admin.stats.available')
+                            </p>
+
+                            <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
+                                @{{ summary.available ?? 0 }}
+                            </p>
+                        </div>
+
+                        <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
+                            <p class="text-xs text-gray-500 dark:text-gray-400">
+                                @lang('gift::app.admin.stats.used')
+                            </p>
+
+                            <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
+                                @{{ summary.used ?? 0 }}
+                            </p>
+                        </div>
+
+                        <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
+                            <p class="text-xs text-gray-500 dark:text-gray-400">
+                                @lang('gift::app.admin.stats.new-total')
+                            </p>
+
+                            <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
+                                @{{ summary.new ?? 0 }}
+                            </p>
+                        </div>
+
+                        <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
+                            <p class="text-xs text-gray-500 dark:text-gray-400">
+                                @lang('gift::app.admin.stats.expired')
+                            </p>
+
+                            <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
+                                @{{ summary.expired ?? 0 }}
+                            </p>
+                        </div>
+                    </div>
+
+                    <!-- 图例 -->
+                    <div class="mt-4 flex flex-wrap justify-center gap-5">
+                        <div class="flex items-center gap-1">
+                            <span class="h-3.5 w-3.5 rounded-md" style="background-color: #598de6"></span>
+
+                            <p class="text-xs text-gray-600 dark:text-gray-300">
+                                @lang('gift::app.admin.stats.series-new')
+                            </p>
+                        </div>
+
+                        <div class="flex items-center gap-1">
+                            <span class="h-3.5 w-3.5 rounded-md" style="background-color: #f87171"></span>
+
+                            <p class="text-xs text-gray-600 dark:text-gray-300">
+                                @lang('gift::app.admin.stats.series-expired')
+                            </p>
+                        </div>
+                    </div>
+
+                    <!-- 图表 -->
+                    <template v-if="isLoading">
+                        <div class="shimmer mt-4 h-[180px] w-full rounded-md"></div>
+                    </template>
+
+                    <template v-else>
+                        <x-admin::charts.bar
+                            ::key="chartVersion"
+                            ::labels="labels"
+                            ::datasets="datasets"
+                            ::aspect-ratio="3"
+                        />
+
+                        <p
+                            v-if="isEmpty"
+                            class="mt-2 text-center text-xs text-gray-500 dark:text-gray-400"
+                        >
+                            @lang('gift::app.admin.stats.empty')
+                        </p>
+                    </template>
+                </div>
+            </div>
+        </script>
+
+        <script type="module">
+            app.component('v-gift-card-statistics', {
+                template: '#v-gift-card-statistics-template',
+
+                data() {
+                    return {
+                        // 默认收起:不请求统计接口,等用户点按钮
+                        isOpen: false,
+
+                        report: {
+                            labels: [],
+                            new: [],
+                            expired: [],
+                            summary: {},
+                        },
+
+                        isLoading: true,
+
+                        // 每次取数后 +1,用来强制图表重建(图表组件只在 mounted 时绘制)
+                        chartVersion: 0,
+
+                        today: "{{ now()->format('Y-m-d') }}",
+
+                        filters: {
+                            start: "{{ now()->subDays(29)->format('Y-m-d') }}",
+
+                            end: "{{ now()->format('Y-m-d') }}",
+                        },
+                    }
+                },
+
+                computed: {
+                    labels() {
+                        return this.report.labels ?? [];
+                    },
+
+                    summary() {
+                        return this.report.summary ?? {};
+                    },
+
+                    datasets() {
+                        return [{
+                            label: "{{ trans('gift::app.admin.stats.series-new') }}",
+                            data: this.report.new ?? [],
+                            backgroundColor: '#598de6',
+                            barThickness: 8,
+                        }, {
+                            label: "{{ trans('gift::app.admin.stats.series-expired') }}",
+                            data: this.report.expired ?? [],
+                            backgroundColor: '#f87171',
+                            barThickness: 8,
+                        }];
+                    },
+
+                    isEmpty() {
+                        if (this.isLoading) {
+                            return false;
+                        }
+
+                        return ! this.summary.new && ! this.summary.expired;
+                    },
+                },
+
+                watch: {
+                    filters: {
+                        handler() {
+                            this.getStats();
+                        },
+
+                        deep: true,
+                    },
+                },
+
+                methods: {
+                    toggle() {
+                        this.isOpen = ! this.isOpen;
+
+                        if (this.isOpen) {
+                            this.getStats();
+                        }
+                    },
+
+                    getStats() {
+                        this.isLoading = true;
+
+                        this.$axios.get("{{ route('admin.gift.statistics') }}", {
+                                params: this.filters
+                            })
+                            .then(response => {
+                                this.report = response.data;
+
+                                this.isLoading = false;
+
+                                this.chartVersion++;
+                            })
+                            .catch(error => {
+                                this.isLoading = false;
+
+                                let message = error.response?.data?.message;
+
+                                if (message) {
+                                    this.$emitter.emit('add-flash', { type: 'error', message });
+                                }
+                            });
+                    },
+
+                    applyQuickRange(days) {
+                        this.filters = {
+                            start: this.shiftDate(this.today, -(days - 1)),
+
+                            end: this.today,
+                        };
+                    },
+
+                    shiftDate(dateString, offsetDays) {
+                        let date = new Date(dateString + 'T00:00:00');
+
+                        date.setDate(date.getDate() + offsetDays);
+
+                        let month = String(date.getMonth() + 1).padStart(2, '0');
+
+                        let day = String(date.getDate()).padStart(2, '0');
+
+                        return `${date.getFullYear()}-${month}-${day}`;
+                    },
+                },
+            });
+        </script>
+    @endPushOnce
 </x-admin::layouts>

+ 1 - 1
packages/Longyi/Gift/src/Resources/views/sales/invoices/view.blade.php

@@ -94,7 +94,7 @@
                         <x-slot:footer>
                             <!-- Save Button -->
                             <x-admin::button
-                                button-type="button"
+                                button-type="submit"
                                 class="primary-button"
                                 :title="trans('admin::app.sales.invoices.view.send')"
                             />

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

@@ -6,6 +6,8 @@ use Longyi\Gift\Http\Controllers\Admin\GiftController;
 Route::group(['middleware' => ['web', 'admin'], 'prefix' => 'admin/gift'], function () {
     Route::controller(GiftController::class)->group(function () {
         Route::get('', 'index')->name('admin.gift.index');
+        // Statistics
+        Route::get('/statistics', 'statistics')->name('admin.gift.statistics');
         // Create & Update
         Route::post('/store', 'store')->name('admin.gift.store');
         Route::get('/edit/{id}', 'edit')->name('admin.gift.edit');

+ 64 - 0
packages/Longyi/Gift/src/Services/GiftCardExpiryService.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Longyi\Gift\Services;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Longyi\Gift\Models\GiftCards;
+
+/**
+ * 礼品卡过期处理。
+ *
+ * 判定规则:
+ * - 只处理「未使用」(giftcard_status = 1)的礼品卡,已使用(2)的卡不会被覆盖;
+ * - expirationdate 不为空且 <= 判定时间,才视为过期;
+ * - expirationdate 为空的卡视为永久有效,不处理;
+ * - 幂等:状态已是「已过期」(3)的卡不会再被命中,重复执行不会产生副作用。
+ */
+class GiftCardExpiryService
+{
+    /**
+     * 默认每批处理条数。
+     */
+    public const DEFAULT_BATCH_SIZE = 500;
+
+    /**
+     * 把已过期的未使用礼品卡标记为「已过期」。
+     *
+     * @param  Carbon|null  $at         过期判定基准时间,默认当前时间
+     * @param  bool         $dryRun     true 时只统计不写库
+     * @param  int          $batchSize  每批处理条数
+     * @return int  被更新(dry-run 时为被判定过期)的礼品卡数量
+     */
+    public function expire(?Carbon $at = null, bool $dryRun = false, int $batchSize = self::DEFAULT_BATCH_SIZE): int
+    {
+        $at = $at ?: now();
+
+        $batchSize = $batchSize > 0 ? $batchSize : self::DEFAULT_BATCH_SIZE;
+
+        $pending = fn () => DB::table('gift_cards')
+            ->where('giftcard_status', GiftCards::STATUS_UNUSED)
+            ->whereNotNull('expirationdate')
+            ->where('expirationdate', '<=', $at);
+
+        if ($dryRun) {
+            return (int) $pending()->count();
+        }
+
+        $total = 0;
+
+        // 按 id 分批,避免一次性锁定/更新过多行。
+        $pending()
+            ->select('id')
+            ->chunkById($batchSize, function ($rows) use (&$total, $at) {
+                $total += DB::table('gift_cards')
+                    ->whereIn('id', $rows->pluck('id')->all())
+                    ->update([
+                        'giftcard_status' => GiftCards::STATUS_EXPIRED,
+                        'updated_at'      => $at,
+                    ]);
+            });
+
+        return $total;
+    }
+}

+ 4 - 0
packages/Longyi/Gift/src/Services/GiftCardService.php

@@ -26,6 +26,10 @@ class GiftCardService
             throw new \Exception('Gift card not found.');
         }
 
+        if ($giftCard->isExpired()) {
+            throw new \Exception('Gift card has expired.');
+        }
+
         $cart = Cart::getCart();
 
         if (!empty($cart->giftcard_amount)) {

+ 147 - 0
packages/Longyi/Gift/src/Services/GiftCardStatisticsService.php

@@ -0,0 +1,147 @@
+<?php
+
+namespace Longyi\Gift\Services;
+
+use Carbon\Carbon;
+use Carbon\CarbonPeriod;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 礼品卡统计。
+ *
+ * - 新增礼品卡:`gift_cards.created_at` 落在统计区间内的数量。
+ * - 过期礼品卡:`gift_cards.expirationdate` 落在统计区间内的数量(不区分是否已使用)。
+ */
+class GiftCardStatisticsService
+{
+    /**
+     * 区间超过这个天数就按月聚合,否则按天。
+     */
+    public const MAX_DAILY_DAYS = 92;
+
+    /**
+     * 单次统计允许的最大区间(天),防止区间过大把图表撑爆。
+     */
+    public const MAX_RANGE_DAYS = 730;
+
+    /**
+     * @return array{
+     *     labels:list<string>,
+     *     granularity:string,
+     *     new:list<int>,
+     *     expired:list<int>,
+     *     summary:array{available:int, used:int, new:int, expired:int}
+     * }
+     */
+    public function get(Carbon $start, Carbon $end): array
+    {
+        [$start, $end] = $this->normalizeRange($start, $end);
+
+        $granularity = $start->diffInDays($end) > self::MAX_DAILY_DAYS ? 'month' : 'day';
+
+        $labels = $this->labels($start, $end, $granularity);
+
+        $format = $granularity === 'month' ? '%Y-%m' : '%Y-%m-%d';
+
+        $newRows = DB::table('gift_cards')
+            ->selectRaw("DATE_FORMAT(created_at, '".$format."') as period, COUNT(*) as total")
+            ->whereBetween('created_at', [$start, $end])
+            ->groupBy('period')
+            ->pluck('total', 'period');
+
+        $expiredRows = DB::table('gift_cards')
+            ->selectRaw("DATE_FORMAT(expirationdate, '".$format."') as period, COUNT(*) as total")
+            ->whereNotNull('expirationdate')
+            ->whereBetween('expirationdate', [$start, $end])
+            ->groupBy('period')
+            ->pluck('total', 'period');
+
+        $new = [];
+        $expired = [];
+
+        foreach ($labels as $label) {
+            $new[] = (int) ($newRows[$label] ?? 0);
+            $expired[] = (int) ($expiredRows[$label] ?? 0);
+        }
+
+        return [
+            'labels'      => $labels,
+            'granularity' => $granularity,
+            'new'         => $new,
+            'expired'     => $expired,
+            'summary'     => [
+                'available' => $this->availableCount(),
+                'used'      => $this->usedCount(),
+                'new'       => array_sum($new),
+                'expired'   => array_sum($expired),
+            ],
+        ];
+    }
+
+    /**
+     * 校正区间:保证 start <= end,并且区间长度不超过 MAX_RANGE_DAYS。
+     *
+     * @return array{0:Carbon, 1:Carbon}
+     */
+    protected function normalizeRange(Carbon $start, Carbon $end): array
+    {
+        $start = $start->copy()->startOfDay();
+        $end = $end->copy()->endOfDay();
+
+        if ($start->greaterThan($end)) {
+            [$start, $end] = [$end->copy()->startOfDay(), $start->copy()->endOfDay()];
+        }
+
+        if ($start->diffInDays($end) > self::MAX_RANGE_DAYS) {
+            $start = $end->copy()->subDays(self::MAX_RANGE_DAYS)->startOfDay();
+        }
+
+        return [$start, $end];
+    }
+
+    /**
+     * 生成完整的横轴标签(没有数据的区间补 0,避免图表出现断点)。
+     *
+     * @return list<string>
+     */
+    protected function labels(Carbon $start, Carbon $end, string $granularity): array
+    {
+        $period = $granularity === 'month'
+            ? CarbonPeriod::create($start->copy()->startOfMonth(), '1 month', $end->copy()->startOfMonth())
+            : CarbonPeriod::create($start->copy(), '1 day', $end->copy());
+
+        $format = $granularity === 'month' ? 'Y-m' : 'Y-m-d';
+
+        $labels = [];
+
+        foreach ($period as $date) {
+            $labels[] = $date->format($format);
+        }
+
+        return $labels;
+    }
+
+    /**
+     * 当前可用(未使用且未过期)的礼品卡数量。
+     */
+    protected function availableCount(): int
+    {
+        return (int) DB::table('gift_cards')
+            ->where('giftcard_status', 1)
+            ->where(function ($query) {
+                $query->whereNull('expirationdate')
+                    ->orWhere('expirationdate', '>=', now());
+            })
+            ->count();
+    }
+
+    /**
+     * 已经用完(状态为已使用)的礼品卡数量。
+     */
+    protected function usedCount(): int
+    {
+        return (int) DB::table('gift_cards')
+            ->where('giftcard_status', 2)
+            ->count();
+    }
+}

+ 6 - 0
packages/Longyi/Member/src/Config/acl.php

@@ -6,5 +6,11 @@ return [
         'name'  => 'Member',
         'route' => 'admin.member.index',
         'sort'  => 101
+    ],
+    [
+        'key'   => 'member.import',
+        'name'  => 'member::app.member.import.title',
+        'route' => 'admin.member.import',
+        'sort'  => 1
     ]
 ];

+ 1 - 0
packages/Longyi/Member/src/DataGrids/Member/MemberDataGrid.php

@@ -90,6 +90,7 @@ class MemberDataGrid extends DataGrid
                     1 => '下单购买',
                     2 => '退款',
                     3 => '取消订单',
+                    4 => '后台导入',
                 ];
 
                 return $typeMap[$row->type] ?? '未知';

+ 90 - 0
packages/Longyi/Member/src/Http/Controllers/Admin/MemberController.php

@@ -3,12 +3,27 @@
 namespace Longyi\Member\Http\Controllers\Admin;
 
 use Illuminate\Http\JsonResponse;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
 use Illuminate\View\View;
 use Longyi\Member\DataGrids\Member\MemberDataGrid;
+use Longyi\Member\Imports\MemberPlusSpreadsheet;
+use Longyi\Member\Services\MemberPlusImportException;
+use Longyi\Member\Services\MemberPlusImportService;
+use Longyi\Member\Services\MemberPlusStatisticsService;
+use Maatwebsite\Excel\Facades\Excel;
 use Webkul\Admin\Http\Controllers\Controller;
 
 class MemberController extends Controller
 {
+    /**
+     * Create a new controller instance.
+     */
+    public function __construct(
+        protected MemberPlusImportService $importService,
+        protected MemberPlusStatisticsService $statisticsService
+    ) {}
+
     /**
      * Display a listing of the resource.
      */
@@ -20,4 +35,79 @@ class MemberController extends Controller
 
         return view('member::admin.index');
     }
+
+    /**
+     * Plus 会员统计:新增 / 过期。
+     */
+    public function statistics(Request $request): JsonResponse
+    {
+        $request->validate([
+            'start' => ['nullable', 'date'],
+            'end'   => ['nullable', 'date'],
+        ]);
+
+        $end = $request->date('end') ?? now();
+
+        $start = $request->date('start') ?? $end->copy()->subDays(29);
+
+        return new JsonResponse($this->statisticsService->get($start, $end));
+    }
+
+    /**
+     * 批量导入 Plus 会员(邮箱 + 过期天数)。
+     */
+    public function import(Request $request): RedirectResponse
+    {
+        $request->validate([
+            'import_file' => [
+                'required',
+                'file',
+                'extensions:xlsx,xls,csv',
+                'mimetypes:text/csv,text/plain,application/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                'max:10240',
+            ],
+        ]);
+
+        try {
+            $sheets = Excel::toArray(new MemberPlusSpreadsheet, $request->file('import_file'));
+
+            $result = $this->importService->import($sheets[0] ?? []);
+        } catch (MemberPlusImportException $e) {
+            return $this->redirectToIndex('error', $e->getMessage());
+        } catch (\Throwable $e) {
+            return $this->redirectToIndex('error', trans('member::app.member.import.errors.generic', [
+                'message' => $this->sanitizeMessage($e->getMessage()),
+            ]));
+        }
+
+        return redirect()
+            ->route('admin.member.index')
+            ->with('member_import_result', $result)
+            ->with(
+                $result['failed'] > 0 ? 'warning' : 'success',
+                trans('member::app.member.import.summary', [
+                    'updated' => $result['updated'],
+                    'failed'  => $result['failed'],
+                    'skipped' => $result['skipped'],
+                ])
+            );
+    }
+
+    /**
+     * 返回列表页并带上提示信息。
+     */
+    protected function redirectToIndex(string $type, string $message): RedirectResponse
+    {
+        return redirect()
+            ->route('admin.member.index')
+            ->with($type, $this->sanitizeMessage($message));
+    }
+
+    /**
+     * 去掉会破坏前端提示脚本的引号与换行。
+     */
+    protected function sanitizeMessage(string $message): string
+    {
+        return trim(str_replace(['"', "\r", "\n"], ["'", ' ', ' '], $message));
+    }
 }

+ 10 - 0
packages/Longyi/Member/src/Imports/MemberPlusSpreadsheet.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Longyi\Member\Imports;
+
+use Maatwebsite\Excel\Concerns\ToArray;
+
+class MemberPlusSpreadsheet implements ToArray
+{
+    public function array(array $array): void {}
+}

+ 50 - 0
packages/Longyi/Member/src/Resources/lang/en/app.php

@@ -5,5 +5,55 @@ return [
         'discount' => 'Plus Fee',
         'vip_active' => 'VIP 会员权益生效中',
         'vip_discount' => 'VIP amount',
+
+        'import' => [
+            'title'         => 'Import Plus',
+            'help'          => 'Upload an Excel file to grant or extend Plus membership by customer email. Members that are still active get the days added on top of the current expiry date, so their remaining days are never shortened.',
+            'file-label'    => 'Excel file',
+            'file-hint'     => 'xlsx / xls / csv supported. The first row must be the headings, with one customer per row.',
+            'columns-title' => 'Expected columns',
+            'columns'       => [
+                'email' => 'email (or 邮箱 / 用户邮箱): customer email, required.',
+                'days'  => 'days (or 天数 / 过期天数): number of days to add, required, positive integer.',
+            ],
+            'submit'        => 'Start import',
+            'result-title'  => 'Import result',
+            'summary'       => 'Updated :updated customer(s), :failed failed row(s), :skipped empty row(s) skipped.',
+            'row'           => 'Row',
+            'email'         => 'Email',
+            'message'       => 'Reason',
+
+            'errors' => [
+                'empty-file'            => 'The uploaded file is empty.',
+                'email-column-missing'  => 'The file must contain an email (邮箱) column.',
+                'days-column-missing'   => 'The file must contain a days (过期天数) column.',
+                'email-required'        => 'The email is empty.',
+                'invalid-email'         => 'The email format is invalid.',
+                'customer-not-found'    => 'No customer found for this email.',
+                'days-required'         => 'The number of days is empty.',
+                'invalid-days'          => 'The number of days must be an integer between 1 and :max.',
+                'generic'               => 'Import failed: :message',
+            ],
+        ],
+
+        'stats' => [
+            'title'          => 'Plus Statistics',
+            'hint'           => 'New: based on the activation records in the member log (purchase or admin import), one customer counts once per day. Expired: based on the membership expiry date. Long ranges are grouped by month.',
+            'show'           => 'Show statistics',
+            'hide'           => 'Collapse',
+            'start-date'     => 'Start date',
+            'end-date'       => 'End date',
+            'range-7'        => 'Last 7 days',
+            'range-30'       => 'Last 30 days',
+            'range-90'       => 'Last 90 days',
+            'range-365'      => 'Last 1 year',
+            'active'         => 'Active members',
+            'expired-total'  => 'Expired (total)',
+            'new-total'      => 'New in range',
+            'expired'        => 'Expired in range',
+            'series-new'     => 'New Plus users',
+            'series-expired' => 'Expired Plus users',
+            'empty'          => 'No data in the selected range.',
+        ],
     ],
 ];

+ 50 - 0
packages/Longyi/Member/src/Resources/lang/zh_CN/app.php

@@ -5,5 +5,55 @@ return [
         'discount' => 'Plus Fee',
         'vip_active' => 'VIP 会员权益生效中',
         'vip_discount' => 'VIP amount',
+
+        'import' => [
+            'title'         => '导入 Plus',
+            'help'          => '上传 Excel,按邮箱为客户开通或延长 Plus 会员。会员仍在有效期内时,会在原到期时间上继续累加,不会缩短客户剩余的会员天数。',
+            'file-label'    => 'Excel 文件',
+            'file-hint'     => '支持 xlsx / xls / csv,第一行必须是表头,一行一个客户。',
+            'columns-title' => '列说明',
+            'columns'       => [
+                'email' => 'email(或 邮箱 / 用户邮箱):客户邮箱,必填。',
+                'days'  => 'days(或 天数 / 过期天数):要增加的会员天数,必填,正整数。',
+            ],
+            'submit'        => '开始导入',
+            'result-title'  => '导入结果',
+            'summary'       => '已导入 :updated 个客户,失败 :failed 行,跳过空行 :skipped 行。',
+            'row'           => '行号',
+            'email'         => '邮箱',
+            'message'       => '原因',
+
+            'errors' => [
+                'empty-file'            => '上传的文件为空。',
+                'email-column-missing'  => '文件必须包含 email(邮箱)列。',
+                'days-column-missing'   => '文件必须包含 days(过期天数)列。',
+                'email-required'        => '邮箱不能为空。',
+                'invalid-email'         => '邮箱格式不正确。',
+                'customer-not-found'    => '找不到该邮箱对应的客户。',
+                'days-required'         => '过期天数不能为空。',
+                'invalid-days'          => '过期天数必须是 1 - :max 之间的整数。',
+                'generic'               => '导入失败::message',
+            ],
+        ],
+
+        'stats' => [
+            'title'          => 'Plus 会员统计',
+            'hint'           => '新增:按会员日志里的激活记录(下单购买 / 后台导入)统计,同一天同一个客户只算 1 个;过期:按客户的会员到期时间统计。区间过长时自动按月汇总。',
+            'show'           => '查看统计',
+            'hide'           => '收起',
+            'start-date'     => '开始日期',
+            'end-date'       => '结束日期',
+            'range-7'        => '近 7 天',
+            'range-30'       => '近 30 天',
+            'range-90'       => '近 90 天',
+            'range-365'      => '近 1 年',
+            'active'         => '当前有效会员',
+            'expired-total'  => '累计已过期',
+            'new-total'      => '区间新增',
+            'expired'        => '区间过期',
+            'series-new'     => '新增 Plus 用户',
+            'series-expired' => '过期 Plus 用户',
+            'empty'          => '所选区间没有数据。',
+        ],
     ],
 ];

+ 445 - 0
packages/Longyi/Member/src/Resources/views/admin/index.blade.php

@@ -3,6 +3,12 @@
         vip管理
     </x-slot>
 
+    @php
+        $importResult = session('member_import_result');
+        $canImport = bouncer()->hasPermission('member.import');
+        $showImportForm = $canImport && (! empty($importResult) || $errors->any());
+    @endphp
+
     @if(session('success'))
         <div class="mb-4 px-4 py-3 bg-green-500 text-white rounded-lg">
             {{ session('success') }}
@@ -13,7 +19,446 @@
         <p class="text-xl text-gray-800 font-bold">
             vip管理列表
         </p>
+
+        @if ($canImport)
+            <button
+                type="button"
+                class="primary-button"
+                onclick="document.getElementById('member-plus-import').classList.toggle('hidden')"
+            >
+                @lang('member::app.member.import.title')
+            </button>
+        @endif
     </div>
 
+    @if ($canImport)
+        <div
+            id="member-plus-import"
+            class="mt-3.5 {{ $showImportForm ? '' : 'hidden' }}"
+        >
+            <x-admin::form
+                :action="route('admin.member.import')"
+                method="POST"
+                enctype="multipart/form-data"
+            >
+                <div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
+                    <p class="mb-4 text-base font-semibold text-gray-800 dark:text-white">
+                        @lang('member::app.member.import.title')
+                    </p>
+
+                    <p class="mb-4 text-sm text-gray-600 dark:text-gray-300">
+                        @lang('member::app.member.import.help')
+                    </p>
+
+                    <x-admin::form.control-group>
+                        <x-admin::form.control-group.label class="required">
+                            @lang('member::app.member.import.file-label')
+                        </x-admin::form.control-group.label>
+
+                        <x-admin::form.control-group.control
+                            type="file"
+                            name="import_file"
+                            rules="required"
+                            accept=".xlsx,.xls,.csv"
+                            :label="trans('member::app.member.import.file-label')"
+                        />
+
+                        <x-admin::form.control-group.error control-name="import_file" />
+
+                        <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                            @lang('member::app.member.import.file-hint')
+                        </p>
+                    </x-admin::form.control-group>
+
+                    <p class="mb-2 text-sm font-semibold text-gray-600 dark:text-gray-300">
+                        @lang('member::app.member.import.columns-title')
+                    </p>
+
+                    <ul class="list-inside list-disc space-y-1 text-sm text-gray-600 dark:text-gray-300">
+                        <li>@lang('member::app.member.import.columns.email')</li>
+                        <li>@lang('member::app.member.import.columns.days')</li>
+                    </ul>
+
+                    <div class="mt-4 flex justify-end">
+                        <button
+                            type="submit"
+                            class="primary-button"
+                        >
+                            @lang('member::app.member.import.submit')
+                        </button>
+                    </div>
+                </div>
+            </x-admin::form>
+        </div>
+    @endif
+
+    @if (! empty($importResult))
+        <div class="mt-3.5 box-shadow rounded bg-white p-4 dark:bg-gray-900">
+            <p class="mb-3 text-base font-semibold text-gray-800 dark:text-white">
+                @lang('member::app.member.import.result-title')
+            </p>
+
+            <p class="mb-3 text-sm text-gray-600 dark:text-gray-300">
+                @lang('member::app.member.import.summary', [
+                    'updated' => $importResult['updated'] ?? 0,
+                    'failed'  => $importResult['failed'] ?? 0,
+                    'skipped' => $importResult['skipped'] ?? 0,
+                ])
+            </p>
+
+            @if (! empty($importResult['errors']))
+                <div class="overflow-x-auto">
+                    <table class="w-full text-left text-sm text-gray-600 dark:text-gray-300">
+                        <thead>
+                            <tr class="border-b dark:border-gray-800">
+                                <th class="px-2 py-2">@lang('member::app.member.import.row')</th>
+                                <th class="px-2 py-2">@lang('member::app.member.import.email')</th>
+                                <th class="px-2 py-2">@lang('member::app.member.import.message')</th>
+                            </tr>
+                        </thead>
+
+                        <tbody>
+                            @foreach ($importResult['errors'] as $error)
+                                <tr class="border-b dark:border-gray-800">
+                                    <td class="px-2 py-2">{{ $error['row'] }}</td>
+                                    <td class="px-2 py-2">{{ $error['email'] }}</td>
+                                    <td class="px-2 py-2 text-red-600">{{ $error['message'] }}</td>
+                                </tr>
+                            @endforeach
+                        </tbody>
+                    </table>
+                </div>
+            @endif
+        </div>
+    @endif
+
+    <!-- Plus 会员统计:默认收起,点按钮才异步加载 -->
+    <v-member-plus-statistics>
+        <!-- Shimmer -->
+        <div class="mt-3.5 box-shadow rounded bg-white dark:bg-gray-900">
+            <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
+                <p class="text-base font-semibold text-gray-800 dark:text-white">
+                    @lang('member::app.member.stats.title')
+                </p>
+
+                <div class="shimmer h-[39px] w-[140px] rounded-md"></div>
+            </div>
+        </div>
+    </v-member-plus-statistics>
+
     <x-admin::datagrid src="{{ route('admin.member.index') }}"></x-admin::datagrid>
+
+    @pushOnce('scripts')
+        <script
+            type="module"
+            src="{{ bagisto_asset('js/chart.js') }}"
+        >
+        </script>
+
+        <script
+            type="text/x-template"
+            id="v-member-plus-statistics-template"
+        >
+            <div class="mt-3.5 box-shadow rounded bg-white dark:bg-gray-900">
+                <!-- 标题 + 展开按钮 -->
+                <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
+                    <div>
+                        <p class="text-base font-semibold text-gray-800 dark:text-white">
+                            @lang('member::app.member.stats.title')
+                        </p>
+
+                        <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                            @lang('member::app.member.stats.hint')
+                        </p>
+                    </div>
+
+                    <button
+                        type="button"
+                        class="secondary-button"
+                        @click="toggle()"
+                    >
+                        <span class="flex items-center gap-x-2.5">
+                            <span v-if="isOpen">@lang('member::app.member.stats.hide')</span>
+
+                            <span v-else>@lang('member::app.member.stats.show')</span>
+
+                            <span
+                                class="text-2xl text-gray-400"
+                                :class="isOpen ? 'icon-sort-up' : 'icon-sort-down'"
+                            ></span>
+                        </span>
+                    </button>
+                </div>
+
+                <!-- 统计内容 -->
+                <div
+                    v-if="isOpen"
+                    class="border-t px-4 py-4 dark:border-gray-800"
+                >
+                    <!-- 时间区间 -->
+                    <div class="flex flex-wrap items-end gap-x-2.5 gap-y-2">
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="applyQuickRange(7)"
+                        >
+                            @lang('member::app.member.stats.range-7')
+                        </button>
+
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="applyQuickRange(30)"
+                        >
+                            @lang('member::app.member.stats.range-30')
+                        </button>
+
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="applyQuickRange(90)"
+                        >
+                            @lang('member::app.member.stats.range-90')
+                        </button>
+
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="applyQuickRange(365)"
+                        >
+                            @lang('member::app.member.stats.range-365')
+                        </button>
+
+                        <x-admin::flat-picker.date class="!w-[140px]" ::allow-input="false">
+                            <input
+                                class="flex min-h-[39px] w-full rounded-md border px-3 py-2 text-sm text-gray-600 transition-all hover:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400"
+                                v-model="filters.start"
+                                placeholder="@lang('member::app.member.stats.start-date')"
+                            />
+                        </x-admin::flat-picker.date>
+
+                        <x-admin::flat-picker.date class="!w-[140px]" ::allow-input="false">
+                            <input
+                                class="flex min-h-[39px] w-full rounded-md border px-3 py-2 text-sm text-gray-600 transition-all hover:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400"
+                                v-model="filters.end"
+                                placeholder="@lang('member::app.member.stats.end-date')"
+                            />
+                        </x-admin::flat-picker.date>
+                    </div>
+
+                    <!-- 概览 -->
+                    <div class="mt-4 grid grid-cols-2 gap-4">
+                        <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
+                            <p class="text-xs text-gray-500 dark:text-gray-400">
+                                @lang('member::app.member.stats.active')
+                            </p>
+
+                            <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
+                                @{{ summary.active ?? 0 }}
+                            </p>
+                        </div>
+
+                        <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
+                            <p class="text-xs text-gray-500 dark:text-gray-400">
+                                @lang('member::app.member.stats.expired-total')
+                            </p>
+
+                            <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
+                                @{{ summary.expired_total ?? 0 }}
+                            </p>
+                        </div>
+
+                        <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
+                            <p class="text-xs text-gray-500 dark:text-gray-400">
+                                @lang('member::app.member.stats.new-total')
+                            </p>
+
+                            <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
+                                @{{ summary.new ?? 0 }}
+                            </p>
+                        </div>
+
+                        <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
+                            <p class="text-xs text-gray-500 dark:text-gray-400">
+                                @lang('member::app.member.stats.expired')
+                            </p>
+
+                            <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
+                                @{{ summary.expired ?? 0 }}
+                            </p>
+                        </div>
+                    </div>
+
+                    <!-- 图例 -->
+                    <div class="mt-4 flex flex-wrap justify-center gap-5">
+                        <div class="flex items-center gap-1">
+                            <span class="h-3.5 w-3.5 rounded-md" style="background-color: #598de6"></span>
+
+                            <p class="text-xs text-gray-600 dark:text-gray-300">
+                                @lang('member::app.member.stats.series-new')
+                            </p>
+                        </div>
+
+                        <div class="flex items-center gap-1">
+                            <span class="h-3.5 w-3.5 rounded-md" style="background-color: #f87171"></span>
+
+                            <p class="text-xs text-gray-600 dark:text-gray-300">
+                                @lang('member::app.member.stats.series-expired')
+                            </p>
+                        </div>
+                    </div>
+
+                    <!-- 图表 -->
+                    <template v-if="isLoading">
+                        <div class="shimmer mt-4 h-[180px] w-full rounded-md"></div>
+                    </template>
+
+                    <template v-else>
+                        <x-admin::charts.bar
+                            ::key="chartVersion"
+                            ::labels="labels"
+                            ::datasets="datasets"
+                            ::aspect-ratio="3"
+                        />
+
+                        <p
+                            v-if="isEmpty"
+                            class="mt-2 text-center text-xs text-gray-500 dark:text-gray-400"
+                        >
+                            @lang('member::app.member.stats.empty')
+                        </p>
+                    </template>
+                </div>
+            </div>
+        </script>
+
+        <script type="module">
+            app.component('v-member-plus-statistics', {
+                template: '#v-member-plus-statistics-template',
+
+                data() {
+                    return {
+                        // 默认收起:不请求统计接口,等用户点按钮
+                        isOpen: false,
+
+                        report: {
+                            labels: [],
+                            new: [],
+                            expired: [],
+                            summary: {},
+                        },
+
+                        isLoading: true,
+
+                        // 每次取数后 +1,用来强制图表重建(图表组件只在 mounted 时绘制)
+                        chartVersion: 0,
+
+                        today: "{{ now()->format('Y-m-d') }}",
+
+                        filters: {
+                            start: "{{ now()->subDays(29)->format('Y-m-d') }}",
+
+                            end: "{{ now()->format('Y-m-d') }}",
+                        },
+                    }
+                },
+
+                computed: {
+                    labels() {
+                        return this.report.labels ?? [];
+                    },
+
+                    summary() {
+                        return this.report.summary ?? {};
+                    },
+
+                    datasets() {
+                        return [{
+                            label: "{{ trans('member::app.member.stats.series-new') }}",
+                            data: this.report.new ?? [],
+                            backgroundColor: '#598de6',
+                            barThickness: 8,
+                        }, {
+                            label: "{{ trans('member::app.member.stats.series-expired') }}",
+                            data: this.report.expired ?? [],
+                            backgroundColor: '#f87171',
+                            barThickness: 8,
+                        }];
+                    },
+
+                    isEmpty() {
+                        if (this.isLoading) {
+                            return false;
+                        }
+
+                        return ! this.summary.new && ! this.summary.expired;
+                    },
+                },
+
+                watch: {
+                    filters: {
+                        handler() {
+                            this.getStats();
+                        },
+
+                        deep: true,
+                    },
+                },
+
+                methods: {
+                    toggle() {
+                        this.isOpen = ! this.isOpen;
+
+                        if (this.isOpen) {
+                            this.getStats();
+                        }
+                    },
+
+                    getStats() {
+                        this.isLoading = true;
+
+                        this.$axios.get("{{ route('admin.member.statistics') }}", {
+                                params: this.filters
+                            })
+                            .then(response => {
+                                this.report = response.data;
+
+                                this.isLoading = false;
+
+                                this.chartVersion++;
+                            })
+                            .catch(error => {
+                                this.isLoading = false;
+
+                                let message = error.response?.data?.message;
+
+                                if (message) {
+                                    this.$emitter.emit('add-flash', { type: 'error', message });
+                                }
+                            });
+                    },
+
+                    applyQuickRange(days) {
+                        this.filters = {
+                            start: this.shiftDate(this.today, -(days - 1)),
+
+                            end: this.today,
+                        };
+                    },
+
+                    shiftDate(dateString, offsetDays) {
+                        let date = new Date(dateString + 'T00:00:00');
+
+                        date.setDate(date.getDate() + offsetDays);
+
+                        let month = String(date.getMonth() + 1).padStart(2, '0');
+
+                        let day = String(date.getDate()).padStart(2, '0');
+
+                        return `${date.getFullYear()}-${month}-${day}`;
+                    },
+                },
+            });
+        </script>
+    @endPushOnce
 </x-admin::layouts>

+ 3 - 1
packages/Longyi/Member/src/Routes/admin-routes.php

@@ -6,5 +6,7 @@ use Longyi\Member\Http\Controllers\Admin\MemberController;
 Route::group(['middleware' => ['web', 'admin'], 'prefix' => 'admin/member'], function () {
     Route::controller(MemberController::class)->group(function () {
         Route::get('', 'index')->name('admin.member.index');
+        Route::get('/statistics', 'statistics')->name('admin.member.statistics');
+        Route::post('/import', 'import')->name('admin.member.import');
     });
-});
+});

+ 9 - 0
packages/Longyi/Member/src/Services/MemberPlusImportException.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Longyi\Member\Services;
+
+use Exception;
+
+class MemberPlusImportException extends Exception
+{
+}

+ 358 - 0
packages/Longyi/Member/src/Services/MemberPlusImportService.php

@@ -0,0 +1,358 @@
+<?php
+
+namespace Longyi\Member\Services;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Longyi\Member\Models\MemberLog;
+use Webkul\Customer\Models\Customer;
+
+/**
+ * 后台批量导入 Plus 会员有效期。
+ *
+ * 表格固定两列(列顺序不限,按表头识别):
+ *   - email / 邮箱 / 用户邮箱:客户邮箱,按邮箱匹配客户
+ *   - days / 天数 / 过期天数 / 会员plus过期天数:要增加的会员天数(正整数)
+ *
+ * 到期时间计算:未过期的从原到期时间往后累加,已过期或从未开通的从当前时间起算,
+ * 因此不会缩短客户已有的会员有效期。
+ */
+class MemberPlusImportService
+{
+    /**
+     * member_log.type:后台导入
+     */
+    public const LOG_TYPE_IMPORT = 4;
+
+    /**
+     * 单次最多可导入的天数(约 10 年),防止误填导致到期时间异常。
+     */
+    public const MAX_DAYS = 3650;
+
+    /**
+     * 结果里最多展示的失败行数,避免 session 过大。
+     */
+    protected const MAX_ERRORS = 200;
+
+    /**
+     * 邮箱列表头别名(比较前会去掉空格、下划线、括号等符号)
+     *
+     * @var list<string>
+     */
+    protected const EMAIL_HEADINGS = [
+        'email',
+        'customeremail',
+        'useremail',
+        '邮箱',
+        '電子郵箱',
+        '电子邮箱',
+        '用户邮箱',
+        '客户邮箱',
+        '客戶郵箱',
+    ];
+
+    /**
+     * 天数列表头别名
+     *
+     * @var list<string>
+     */
+    protected const DAYS_HEADINGS = [
+        'days',
+        'day',
+        'expiredays',
+        'expireday',
+        '过期天数',
+        '過期天數',
+        '会员过期天数',
+        '会员plus过期天数',
+        'plus过期天数',
+        'vip过期天数',
+        '天数',
+        '剩余天数',
+    ];
+
+    /**
+     * lower(email) => customer id,只加载一次
+     *
+     * @var array<string, int>
+     */
+    protected array $customersByEmail = [];
+
+    protected bool $customersLoaded = false;
+
+    /**
+     * @param  list<list<mixed>>  $table  第一行是表头
+     * @return array{total:int, updated:int, skipped:int, failed:int, errors:list<array{row:int, email:string, message:string}>}
+     */
+    public function import(array $table): array
+    {
+        $result = [
+            'total'   => 0,
+            'updated' => 0,
+            'skipped' => 0,
+            'failed'  => 0,
+            'errors'  => [],
+        ];
+
+        if ($table === []) {
+            throw new MemberPlusImportException(trans('member::app.member.import.errors.empty-file'));
+        }
+
+        [$emailIndex, $daysIndex] = $this->resolveColumns((array) array_shift($table));
+
+        foreach ($table as $index => $line) {
+            $line = (array) $line;
+
+            // Excel 里的实际行号(表头占第 1 行)
+            $excelRow = $index + 2;
+
+            $email = $this->normalizeEmail($line[$emailIndex] ?? null);
+            $days = $line[$daysIndex] ?? null;
+
+            // 整行空白(表格尾部的空行)直接跳过
+            if ($email === '' && $this->isEmptyValue($days)) {
+                $result['skipped']++;
+
+                continue;
+            }
+
+            $result['total']++;
+
+            $message = $this->applyRow($email, $days);
+
+            if ($message === null) {
+                $result['updated']++;
+
+                continue;
+            }
+
+            $result['failed']++;
+
+            if (count($result['errors']) < self::MAX_ERRORS) {
+                $result['errors'][] = [
+                    'row'     => $excelRow,
+                    'email'   => $email,
+                    'message' => $message,
+                ];
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * 处理单行,成功返回 null,失败返回错误信息。
+     */
+    protected function applyRow(string $email, mixed $daysRaw): ?string
+    {
+        if ($email === '') {
+            return trans('member::app.member.import.errors.email-required');
+        }
+
+        if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
+            return trans('member::app.member.import.errors.invalid-email');
+        }
+
+        if ($this->isEmptyValue($daysRaw)) {
+            return trans('member::app.member.import.errors.days-required');
+        }
+
+        $days = $this->normalizeDays($daysRaw);
+
+        if ($days === null || $days < 1 || $days > self::MAX_DAYS) {
+            return trans('member::app.member.import.errors.invalid-days', ['max' => self::MAX_DAYS]);
+        }
+
+        $customerId = $this->customerIdByEmail($email);
+
+        $customer = $customerId ? Customer::find($customerId) : null;
+
+        if (! $customer) {
+            return trans('member::app.member.import.errors.customer-not-found');
+        }
+
+        $previousExpireDate = $customer->vip_expire_date;
+        $currentExpireDate = $previousExpireDate ? Carbon::parse($previousExpireDate) : null;
+
+        // 未过期则从原到期时间继续累加,已过期/未开通则从现在起算
+        $baseDate = ($currentExpireDate && $currentExpireDate->isFuture())
+            ? $currentExpireDate->copy()
+            : now();
+
+        $newExpireDate = $baseDate->addDays($days);
+
+        DB::transaction(function () use ($customer, $previousExpireDate, $newExpireDate) {
+            $customer->vip_expire_date = $newExpireDate;
+            $customer->save();
+
+            MemberLog::create([
+                'customer_id'          => $customer->id,
+                'order_id'             => 0,
+                'type'                 => self::LOG_TYPE_IMPORT,
+                'amount'               => 0,
+                'previous_expire_date' => $previousExpireDate,
+                'expirationdate'       => $newExpireDate,
+            ]);
+        });
+
+        return null;
+    }
+
+    /**
+     * 识别邮箱列与天数列。
+     *
+     * @param  list<mixed>  $headings
+     * @return array{0:int, 1:int}
+     */
+    protected function resolveColumns(array $headings): array
+    {
+        $emailIndex = null;
+        $daysIndex = null;
+
+        foreach ($headings as $index => $heading) {
+            $normalized = $this->normalizeHeading($heading);
+
+            if ($normalized === '') {
+                continue;
+            }
+
+            if ($emailIndex === null && in_array($normalized, self::EMAIL_HEADINGS, true)) {
+                $emailIndex = (int) $index;
+            }
+
+            if ($daysIndex === null && in_array($normalized, self::DAYS_HEADINGS, true)) {
+                $daysIndex = (int) $index;
+            }
+        }
+
+        if ($emailIndex === null) {
+            throw new MemberPlusImportException(trans('member::app.member.import.errors.email-column-missing'));
+        }
+
+        if ($daysIndex === null) {
+            throw new MemberPlusImportException(trans('member::app.member.import.errors.days-column-missing'));
+        }
+
+        return [$emailIndex, $daysIndex];
+    }
+
+    /**
+     * 通过邮箱找客户 ID,匹配时忽略大小写。
+     */
+    protected function customerIdByEmail(string $email): ?int
+    {
+        if (! $this->customersLoaded) {
+            $this->loadCustomersByEmail();
+        }
+
+        return $this->customersByEmail[$email] ?? null;
+    }
+
+    /**
+     * 一次性载入 lower(email) => id,避免每行都做全表扫描。
+     */
+    protected function loadCustomersByEmail(): void
+    {
+        DB::table('customers')
+            ->select(['id', 'email'])
+            ->chunkById(1000, function ($rows) {
+                foreach ($rows as $row) {
+                    $email = strtolower(trim((string) $row->email));
+
+                    if ($email !== '' && ! isset($this->customersByEmail[$email])) {
+                        $this->customersByEmail[$email] = (int) $row->id;
+                    }
+                }
+            });
+
+        $this->customersLoaded = true;
+    }
+
+    /**
+     * 表头归一化:小写、去空白、去常见符号。
+     */
+    protected function normalizeHeading(mixed $heading): string
+    {
+        $text = strtolower(trim((string) $heading, " \t\n\r\0\x0B\xC2\xA0"));
+
+        return str_replace(
+            [' ', '_', '-', '.', '(', ')', '(', ')', ':', ':', '*'],
+            '',
+            $text
+        );
+    }
+
+    protected function normalizeEmail(mixed $value): string
+    {
+        return strtolower($this->cellString($value));
+    }
+
+    /**
+     * 天数归一化,无法解析时返回 null。
+     */
+    protected function normalizeDays(mixed $value): ?int
+    {
+        if ($this->isEmptyValue($value)) {
+            return null;
+        }
+
+        if (is_int($value) || is_float($value)) {
+            $number = (float) $value;
+        } else {
+            $text = str_replace([',', ','], '', $this->cellString($value));
+
+            // 兼容 "30天" / "30 days" 这类写法
+            $text = preg_replace('/[^0-9.\-]/', '', $text);
+
+            if ($text === null || $text === '' || ! is_numeric($text)) {
+                return null;
+            }
+
+            $number = (float) $text;
+        }
+
+        if (floor($number) != $number) {
+            return null;
+        }
+
+        return (int) $number;
+    }
+
+    protected function isEmptyValue(mixed $value): bool
+    {
+        if ($value === null) {
+            return true;
+        }
+
+        if (is_string($value)) {
+            return trim($value) === '';
+        }
+
+        return false;
+    }
+
+    protected function cellString(mixed $value): string
+    {
+        if ($value === null) {
+            return '';
+        }
+
+        if (is_bool($value)) {
+            return $value ? '1' : '0';
+        }
+
+        if (is_int($value)) {
+            return (string) $value;
+        }
+
+        if (is_float($value)) {
+            if (floor($value) == $value) {
+                return (string) (int) $value;
+            }
+
+            return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.');
+        }
+
+        return trim((string) $value);
+    }
+}

+ 156 - 0
packages/Longyi/Member/src/Services/MemberPlusStatisticsService.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace Longyi\Member\Services;
+
+use Carbon\Carbon;
+use Carbon\CarbonPeriod;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * Plus 会员统计。
+ *
+ * - 新增 Plus 用户:`member_log` 里 type=1(下单购买)/ type=4(后台导入)的激活记录,
+ *   按记录时间归集;同一客户同一天多次激活只算 1 个。
+ * - 过期 Plus 用户:`customers.vip_expire_date` 落在统计区间内的客户数。
+ *
+ * 注意:老站迁移过来的历史会员没有 member_log 记录,只体现在「过期」里,不计入「新增」。
+ */
+class MemberPlusStatisticsService
+{
+    /**
+     * 计入「新增」的 member_log 类型
+     *
+     * @var list<int>
+     */
+    public const ACTIVATION_TYPES = [1, 4];
+
+    /**
+     * 区间超过这个天数就按月聚合,否则按天。
+     */
+    public const MAX_DAILY_DAYS = 92;
+
+    /**
+     * 单次统计允许的最大区间(天),防止区间过大把图表撑爆。
+     */
+    public const MAX_RANGE_DAYS = 730;
+
+    /**
+     * @return array{
+     *     labels:list<string>,
+     *     granularity:string,
+     *     new:list<int>,
+     *     expired:list<int>,
+     *     summary:array{active:int, expired_total:int, new:int, expired:int}
+     * }
+     */
+    public function get(Carbon $start, Carbon $end): array
+    {
+        [$start, $end] = $this->normalizeRange($start, $end);
+
+        $granularity = $start->diffInDays($end) > self::MAX_DAILY_DAYS ? 'month' : 'day';
+
+        $labels = $this->labels($start, $end, $granularity);
+
+        $format = $granularity === 'month' ? '%Y-%m' : '%Y-%m-%d';
+
+        $newRows = DB::table('member_log')
+            ->selectRaw("DATE_FORMAT(created_at, '".$format."') as period, COUNT(DISTINCT customer_id) as total")
+            ->whereIn('type', self::ACTIVATION_TYPES)
+            ->whereBetween('created_at', [$start, $end])
+            ->groupBy('period')
+            ->pluck('total', 'period');
+
+        $expiredRows = DB::table('customers')
+            ->selectRaw("DATE_FORMAT(vip_expire_date, '".$format."') as period, COUNT(*) as total")
+            ->whereNotNull('vip_expire_date')
+            ->whereBetween('vip_expire_date', [$start, $end])
+            ->groupBy('period')
+            ->pluck('total', 'period');
+
+        $new = [];
+        $expired = [];
+
+        foreach ($labels as $label) {
+            $new[] = (int) ($newRows[$label] ?? 0);
+            $expired[] = (int) ($expiredRows[$label] ?? 0);
+        }
+
+        return [
+            'labels'      => $labels,
+            'granularity' => $granularity,
+            'new'         => $new,
+            'expired'     => $expired,
+            'summary'     => [
+                'active'        => $this->activeCount(),
+                'expired_total' => $this->expiredCount(),
+                'new'           => array_sum($new),
+                'expired'       => array_sum($expired),
+            ],
+        ];
+    }
+
+    /**
+     * 校正区间:保证 start <= end,并且区间长度不超过 MAX_RANGE_DAYS。
+     *
+     * @return array{0:Carbon, 1:Carbon}
+     */
+    protected function normalizeRange(Carbon $start, Carbon $end): array
+    {
+        $start = $start->copy()->startOfDay();
+        $end = $end->copy()->endOfDay();
+
+        if ($start->greaterThan($end)) {
+            [$start, $end] = [$end->copy()->startOfDay(), $start->copy()->endOfDay()];
+        }
+
+        if ($start->diffInDays($end) > self::MAX_RANGE_DAYS) {
+            $start = $end->copy()->subDays(self::MAX_RANGE_DAYS)->startOfDay();
+        }
+
+        return [$start, $end];
+    }
+
+    /**
+     * 生成完整的横轴标签(没有数据的区间补 0,避免图表出现断点)。
+     *
+     * @return list<string>
+     */
+    protected function labels(Carbon $start, Carbon $end, string $granularity): array
+    {
+        $period = $granularity === 'month'
+            ? CarbonPeriod::create($start->copy()->startOfMonth(), '1 month', $end->copy()->startOfMonth())
+            : CarbonPeriod::create($start->copy(), '1 day', $end->copy());
+
+        $format = $granularity === 'month' ? 'Y-m' : 'Y-m-d';
+
+        $labels = [];
+
+        foreach ($period as $date) {
+            $labels[] = $date->format($format);
+        }
+
+        return $labels;
+    }
+
+    /**
+     * 当前仍在有效期内的 Plus 会员数。
+     */
+    protected function activeCount(): int
+    {
+        return (int) DB::table('customers')
+            ->whereNotNull('vip_expire_date')
+            ->where('vip_expire_date', '>=', now())
+            ->count();
+    }
+
+    /**
+     * 当前已经过期的 Plus 会员数。
+     */
+    protected function expiredCount(): int
+    {
+        return (int) DB::table('customers')
+            ->whereNotNull('vip_expire_date')
+            ->where('vip_expire_date', '<', now())
+            ->count();
+    }
+}

+ 7 - 2
packages/Webkul/Admin/src/Resources/views/settings/exchange-rates/index.blade.php

@@ -229,7 +229,7 @@
                         <x-slot:footer>
                             <!-- Save Button -->
                             <x-admin::button
-                                button-type="button"
+                                button-type="submit"
                                 class="primary-button"
                                 :title="trans('admin::app.settings.exchange-rates.index.create.save-btn')"
                                 ::loading="isLoading"
@@ -299,8 +299,13 @@
                             .catch(error => {
                                 this.isLoading = false;
 
-                                if (error.response.status == 422) {
+                                if (error.response?.status == 422) {
                                     setErrors(error.response.data.errors);
+                                } else {
+                                    this.$emitter.emit('add-flash', {
+                                        type: 'error',
+                                        message: error.response?.data?.message ?? error.message
+                                    });
                                 }
                             });
                     },

+ 4 - 0
packages/Webkul/Shop/src/Listeners/ThemeCacheCleaner.php

@@ -11,6 +11,10 @@ class ThemeCacheCleaner
      */
     protected function clearHomeCache($theme): void
     {
+        if (! $theme->channel) {
+            return;
+        }
+
         $localeCodes = array_keys(core()->getAllLocales()->toArray()) ?: [core()->getDefaultLocaleCode()];
 
         foreach ($localeCodes as $locale) {

+ 10 - 0
packages/Webkul/Theme/src/Models/ThemeCustomization.php

@@ -4,8 +4,10 @@ namespace Webkul\Theme\Models;
 
 use Illuminate\Database\Eloquent\Factories\Factory;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Webkul\Admin\Database\Factories\ThemeFactory;
 use Webkul\Core\Eloquent\TranslatableModel;
+use Webkul\Core\Models\ChannelProxy;
 use Webkul\Theme\Contracts\ThemeCustomization as ThemeCustomizationContract;
 
 class ThemeCustomization extends TranslatableModel implements ThemeCustomizationContract
@@ -101,4 +103,12 @@ class ThemeCustomization extends TranslatableModel implements ThemeCustomization
     {
         return ThemeFactory::new();
     }
+
+    /**
+     * Get the channel associated with the theme customization.
+     */
+    public function channel(): BelongsTo
+    {
+        return $this->belongsTo(ChannelProxy::modelClass(), 'channel_id');
+    }
 }