Ver código fonte

添加会员plus导入和统计

llp 5 dias atrás
pai
commit
9cf4293c61

+ 21 - 1
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.
      */

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

@@ -27,6 +27,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',

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

@@ -27,6 +27,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'                   => '保存',

+ 336 - 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"
@@ -278,7 +292,10 @@
                 data() {
                     return {
                         selectedGift: 0,
-                        selectedGiftData: {},
+                        // giftcard_status 默认未使用(1)
+                        selectedGiftData: {
+                            giftcard_status: '1',
+                        },
                         isLoading: false,
                         emailValidationMessage: '',
                         emailDebounceTimer: null,
@@ -409,11 +426,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>

+ 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');

+ 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();
+    }
+}