| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753 |
- <x-admin::layouts>
- <x-slot:title>
- @lang('gift::app.admin.title')
- </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>
- @pushOnce('scripts')
- <script
- type="text/x-template"
- id="v-gift-cards-template"
- >
- <div>
- <div class="flex items-center justify-between">
- <p class="text-xl font-bold text-gray-800 dark:text-white">
- @lang('gift::app.admin.title')
- </p>
- <div class="flex items-center gap-x-2.5">
- @if (bouncer()->hasPermission('gift.create'))
- <button
- type="button"
- class="primary-button"
- @click="selectedGift=0; resetForm(); $refs.giftUpdateOrCreateModal.toggle()"
- >
- @lang('gift::app.admin.index.create-btn')
- </button>
- @endif
- </div>
- </div>
- <!-- Gift Card Statistics(默认收起,点按钮才异步加载) -->
- <v-gift-card-statistics></v-gift-card-statistics>
- <x-admin::datagrid
- :src="route('admin.gift.index')"
- ref="datagrid"
- >
- <template #body="{
- isLoading,
- available,
- applied,
- selectAll,
- sort,
- performAction
- }">
- <template v-if="isLoading">
- <x-admin::shimmer.datagrid.table.body />
- </template>
- <template v-else>
- <div
- v-for="record in available.records"
- class="row grid items-center gap-2.5 border-b px-4 py-4 text-gray-600 transition-all hover:bg-gray-50 dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-950"
- :style="`grid-template-columns: repeat(${gridsCount}, minmax(0, 1fr))`"
- >
- <!-- Mass Actions Checkbox -->
- <p v-if="available.massActions.length">
- <label :for="`mass_action_select_record_${record[available.meta.primary_column]}`">
- <input
- type="checkbox"
- :name="`mass_action_select_record_${record[available.meta.primary_column]}`"
- :value="record[available.meta.primary_column]"
- :id="`mass_action_select_record_${record[available.meta.primary_column]}`"
- class="peer hidden"
- v-model="applied.massActions.indices"
- >
- <span class="icon-uncheckbox peer-checked:icon-checked cursor-pointer rounded-md text-2xl peer-checked:text-blue-600"></span>
- </label>
- </p>
- <!-- ID -->
- <p>@{{ record.id }}</p>
- <!-- Card Amount -->
- <p>@{{ record.giftcard_amount }}</p>
- <!-- Used Amount -->
- <p>@{{ record.used_giftcard_amount }}</p>
- <!-- Remaining Amount -->
- <p>@{{ record.remaining_giftcard_amount }}</p>
- <!-- Customer Email -->
- <p v-html="record.customer_email"></p>
- <!-- Expiration Date -->
- <p>@{{ record.expirationdate }}</p>
- <!-- Status -->
- <p v-html="record.giftcard_status"></p>
- <!-- Notes -->
- <p>@{{ record.notes }}</p>
- <!-- Created At -->
- <p>@{{ record.created_at }}</p>
- <!-- Actions -->
- <div class="flex justify-end">
- @if (bouncer()->hasPermission('gift.edit'))
- <a @click="selectedGift=1; editModal(record.actions.find(action => action.index === 'edit')?.url)">
- <span
- :class="record.actions.find(action => action.index === 'edit')?.icon"
- class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"
- >
- </span>
- </a>
- @endif
- @if (bouncer()->hasPermission('gift.delete'))
- <a @click="performAction(record.actions.find(action => action.index === 'delete'))">
- <span
- :class="record.actions.find(action => action.index === 'delete')?.icon"
- class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"
- >
- </span>
- </a>
- @endif
- </div>
- </div>
- </template>
- </template>
- </x-admin::datagrid>
- <!-- Create / Edit Modal -->
- <x-admin::form
- v-slot="{ meta, errors, handleSubmit }"
- as="div"
- ref="modalForm"
- >
- <form
- @submit="handleSubmit($event, updateOrCreate)"
- ref="giftCreateForm"
- >
- <x-admin::modal ref="giftUpdateOrCreateModal">
- <x-slot:header>
- <p class="text-lg font-bold text-gray-800 dark:text-white">
- <span v-if="selectedGift">
- @lang('gift::app.admin.edit.title')
- </span>
- <span v-else>
- @lang('gift::app.admin.create.title')
- </span>
- </p>
- </x-slot>
- <x-slot:content>
- <x-admin::form.control-group.control
- type="hidden"
- name="id"
- v-model="selectedGiftData.id"
- />
- <!-- 编辑模式:显示只读信息 + 可修改的过期时间和状态 -->
- <template v-if="selectedGift">
- <!-- Gift Card Number (只读) -->
- <div class="mb-4 rounded-md bg-gray-50 p-4 dark:bg-gray-800">
- <div class="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span class="text-gray-500">@lang('gift::app.admin.datagrid.giftcard-number')</span>
- <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.giftcard_number }}</p>
- </div>
- <div>
- <span class="text-gray-500">@lang('gift::app.admin.create.giftcard-amount')</span>
- <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.giftcard_amount }}</p>
- </div>
- <div>
- <span class="text-gray-500">@lang('gift::app.admin.datagrid.used-amount')</span>
- <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.used_giftcard_amount }}</p>
- </div>
- <div>
- <span class="text-gray-500">@lang('gift::app.admin.datagrid.remaining-amount')</span>
- <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.remaining_giftcard_amount }}</p>
- </div>
- <div>
- <span class="text-gray-500">用户邮箱</span>
- <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.customer_email || '未绑定' }}</p>
- </div>
- </div>
- </div>
- </template>
- <!-- 创建模式:显示金额和邮箱 -->
- <template v-else>
- <!-- Card Amount -->
- <x-admin::form.control-group>
- <x-admin::form.control-group.label class="required">
- @lang('gift::app.admin.create.giftcard-amount')
- </x-admin::form.control-group.label>
- <x-admin::form.control-group.control
- type="text"
- name="giftcard_amount"
- rules="required|numeric|min:0"
- v-model="selectedGiftData.giftcard_amount"
- :label="trans('gift::app.admin.create.giftcard-amount')"
- placeholder="0.00"
- />
- <x-admin::form.control-group.error control-name="giftcard_amount" />
- </x-admin::form.control-group>
- <!-- Customer Email -->
- <x-admin::form.control-group>
- <x-admin::form.control-group.label>
- @lang('gift::app.admin.create.customer-email')
- </x-admin::form.control-group.label>
- <x-admin::form.control-group.control
- type="text"
- name="customer_email"
- v-model="selectedGiftData.customer_email"
- :label="trans('gift::app.admin.create.customer-email')"
- placeholder="@lang('gift::app.admin.create.customer-email-placeholder')"
- />
- <input type="hidden" name="customer_id" v-model="selectedGiftData.customer_id" />
- <div v-if="emailValidationMessage" v-html="emailValidationMessage" class="mt-1 text-sm"></div>
- <x-admin::form.control-group.error control-name="customer_email" />
- <x-admin::form.control-group.error control-name="customer_id" />
- </x-admin::form.control-group>
- </template>
- <!-- Expiration Date (创建和编辑都显示) -->
- <x-admin::form.control-group>
- <x-admin::form.control-group.label class="required">
- @lang('gift::app.admin.create.expirationdate')
- </x-admin::form.control-group.label>
- <x-admin::form.control-group.control
- type="datetime"
- name="expirationdate"
- rules="required"
- v-model="selectedGiftData.expirationdate"
- :label="trans('gift::app.admin.create.expirationdate')"
- />
- <x-admin::form.control-group.error control-name="expirationdate" />
- </x-admin::form.control-group>
- <!-- Status (创建和编辑都显示) -->
- <x-admin::form.control-group>
- <x-admin::form.control-group.label>
- @lang('gift::app.admin.create.giftcard-status')
- </x-admin::form.control-group.label>
- <x-admin::form.control-group.control
- type="select"
- name="giftcard_status"
- v-model="selectedGiftData.giftcard_status"
- >
- <option value="1">@lang('gift::app.admin.create.unused')</option>
- <option value="2">@lang('gift::app.admin.create.used')</option>
- </x-admin::form.control-group.control>
- <x-admin::form.control-group.error control-name="giftcard_status" />
- </x-admin::form.control-group>
- </x-slot>
- <x-slot:footer>
- <x-admin::button
- button-type="button"
- class="secondary-button mr-2"
- :title="trans('gift::app.admin.create.back-btn')"
- @click="$refs.giftUpdateOrCreateModal.close()"
- />
- <x-admin::button
- button-type="submit"
- class="primary-button"
- :title="trans('gift::app.admin.create.save-btn')"
- ::loading="isLoading"
- ::disabled="isLoading"
- />
- </x-slot>
- </x-admin::modal>
- </form>
- </x-admin::form>
- </div>
- </script>
- <script type="module">
- app.component('v-gift-cards', {
- template: '#v-gift-cards-template',
- data() {
- return {
- selectedGift: 0,
- // giftcard_status 默认未使用(1)
- selectedGiftData: {
- giftcard_status: '1',
- },
- isLoading: false,
- emailValidationMessage: '',
- emailDebounceTimer: null,
- }
- },
- watch: {
- 'selectedGiftData.customer_email': {
- handler(newEmail) {
- // 清除上一次的定时器
- if (this.emailDebounceTimer) {
- clearTimeout(this.emailDebounceTimer);
- }
- // 编辑模式不触发验证
- if (this.selectedGift) {
- return;
- }
- // 500ms 防抖
- this.emailDebounceTimer = setTimeout(() => {
- this.validateEmail(newEmail);
- }, 500);
- },
- immediate: false
- }
- },
- computed: {
- gridsCount() {
- let count = this.$refs.datagrid.available.columns.length;
- if (this.$refs.datagrid.available.actions.length) {
- ++count;
- }
- if (this.$refs.datagrid.available.massActions.length) {
- ++count;
- }
- return count;
- },
- },
- methods: {
- updateOrCreate(params, { resetForm, setErrors }) {
- // 创建模式:检查邮箱是否已填写且验证通过
- if (!params.id && this.selectedGiftData.customer_email && !this.selectedGiftData.customer_id) {
- this.$emitter.emit('add-flash', { type: 'error', message: '请等待邮箱验证通过后再提交' });
- return;
- }
- this.isLoading = true;
- let formData = new FormData(this.$refs.giftCreateForm);
- if (params.id) {
- formData.append('_method', 'put');
- }
- this.$axios.post(
- params.id
- ? "{{ route('admin.gift.update') }}"
- : "{{ route('admin.gift.store') }}",
- formData
- )
- .then((response) => {
- this.isLoading = false;
- this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
- this.$refs.giftUpdateOrCreateModal.close();
- this.$refs.datagrid.get();
- resetForm();
- this.emailValidationMessage = '';
- })
- .catch(error => {
- this.isLoading = false;
- if (error.response.status == 422) {
- setErrors(error.response.data.errors);
- }
- });
- },
- editModal(url) {
- this.$axios.get(url)
- .then((response) => {
- this.selectedGiftData = response.data.data;
- // 类型转换:确保 select 能正确匹配
- if (this.selectedGiftData.giftcard_status !== undefined) {
- this.selectedGiftData.giftcard_status = String(this.selectedGiftData.giftcard_status);
- }
- // 格式化过期时间为 flat-picker 需要的格式 Y-m-d H:i:s
- if (this.selectedGiftData.expirationdate) {
- this.selectedGiftData.expirationdate = this.selectedGiftData.expirationdate.replace('T', ' ').replace(/\.\d+Z$/, '');
- }
- this.emailValidationMessage = '';
- this.$refs.giftUpdateOrCreateModal.toggle();
- })
- .catch(error => this.$emitter.emit('add-flash', {
- type: 'error', message: error.response.data.message
- }));
- },
- validateEmail(email) {
- this.emailValidationMessage = '';
- if (!email) {
- this.selectedGiftData.customer_id = null;
- return;
- }
- this.emailValidationMessage = '<span class="text-gray-500">@lang("gift::app.admin.create.validating")</span>';
- this.$axios.post(
- "{{ route('admin.gift.validate_email') }}",
- { email: email }
- )
- .then((response) => {
- if (response.data.valid) {
- this.emailValidationMessage = '<span class="text-green-600">' + response.data.message + ' (' + response.data.customer_name + ')</span>';
- this.selectedGiftData.customer_id = response.data.customer_id;
- } else {
- this.emailValidationMessage = '<span class="text-red-600">' + response.data.message + '</span>';
- this.selectedGiftData.customer_id = null;
- }
- })
- .catch(error => {
- this.emailValidationMessage = '<span class="text-red-600">@lang("gift::app.admin.create.validation-failed")</span>';
- this.selectedGiftData.customer_id = null;
- });
- },
- resetForm() {
- // 新建时状态默认未使用(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>
|