| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464 |
- <x-admin::layouts>
- <x-slot:title>
- 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') }}
- </div>
- @endif
- <div class="flex justify-between items-center">
- <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>
|