index.blade.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. <x-admin::layouts>
  2. <x-slot:title>
  3. vip管理
  4. </x-slot>
  5. @php
  6. $importResult = session('member_import_result');
  7. $canImport = bouncer()->hasPermission('member.import');
  8. $showImportForm = $canImport && (! empty($importResult) || $errors->any());
  9. @endphp
  10. @if(session('success'))
  11. <div class="mb-4 px-4 py-3 bg-green-500 text-white rounded-lg">
  12. {{ session('success') }}
  13. </div>
  14. @endif
  15. <div class="flex justify-between items-center">
  16. <p class="text-xl text-gray-800 font-bold">
  17. vip管理列表
  18. </p>
  19. @if ($canImport)
  20. <button
  21. type="button"
  22. class="primary-button"
  23. onclick="document.getElementById('member-plus-import').classList.toggle('hidden')"
  24. >
  25. @lang('member::app.member.import.title')
  26. </button>
  27. @endif
  28. </div>
  29. @if ($canImport)
  30. <div
  31. id="member-plus-import"
  32. class="mt-3.5 {{ $showImportForm ? '' : 'hidden' }}"
  33. >
  34. <x-admin::form
  35. :action="route('admin.member.import')"
  36. method="POST"
  37. enctype="multipart/form-data"
  38. >
  39. <div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
  40. <p class="mb-4 text-base font-semibold text-gray-800 dark:text-white">
  41. @lang('member::app.member.import.title')
  42. </p>
  43. <p class="mb-4 text-sm text-gray-600 dark:text-gray-300">
  44. @lang('member::app.member.import.help')
  45. </p>
  46. <x-admin::form.control-group>
  47. <x-admin::form.control-group.label class="required">
  48. @lang('member::app.member.import.file-label')
  49. </x-admin::form.control-group.label>
  50. <x-admin::form.control-group.control
  51. type="file"
  52. name="import_file"
  53. rules="required"
  54. accept=".xlsx,.xls,.csv"
  55. :label="trans('member::app.member.import.file-label')"
  56. />
  57. <x-admin::form.control-group.error control-name="import_file" />
  58. <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
  59. @lang('member::app.member.import.file-hint')
  60. </p>
  61. </x-admin::form.control-group>
  62. <p class="mb-2 text-sm font-semibold text-gray-600 dark:text-gray-300">
  63. @lang('member::app.member.import.columns-title')
  64. </p>
  65. <ul class="list-inside list-disc space-y-1 text-sm text-gray-600 dark:text-gray-300">
  66. <li>@lang('member::app.member.import.columns.email')</li>
  67. <li>@lang('member::app.member.import.columns.days')</li>
  68. </ul>
  69. <div class="mt-4 flex justify-end">
  70. <button
  71. type="submit"
  72. class="primary-button"
  73. >
  74. @lang('member::app.member.import.submit')
  75. </button>
  76. </div>
  77. </div>
  78. </x-admin::form>
  79. </div>
  80. @endif
  81. @if (! empty($importResult))
  82. <div class="mt-3.5 box-shadow rounded bg-white p-4 dark:bg-gray-900">
  83. <p class="mb-3 text-base font-semibold text-gray-800 dark:text-white">
  84. @lang('member::app.member.import.result-title')
  85. </p>
  86. <p class="mb-3 text-sm text-gray-600 dark:text-gray-300">
  87. @lang('member::app.member.import.summary', [
  88. 'updated' => $importResult['updated'] ?? 0,
  89. 'failed' => $importResult['failed'] ?? 0,
  90. 'skipped' => $importResult['skipped'] ?? 0,
  91. ])
  92. </p>
  93. @if (! empty($importResult['errors']))
  94. <div class="overflow-x-auto">
  95. <table class="w-full text-left text-sm text-gray-600 dark:text-gray-300">
  96. <thead>
  97. <tr class="border-b dark:border-gray-800">
  98. <th class="px-2 py-2">@lang('member::app.member.import.row')</th>
  99. <th class="px-2 py-2">@lang('member::app.member.import.email')</th>
  100. <th class="px-2 py-2">@lang('member::app.member.import.message')</th>
  101. </tr>
  102. </thead>
  103. <tbody>
  104. @foreach ($importResult['errors'] as $error)
  105. <tr class="border-b dark:border-gray-800">
  106. <td class="px-2 py-2">{{ $error['row'] }}</td>
  107. <td class="px-2 py-2">{{ $error['email'] }}</td>
  108. <td class="px-2 py-2 text-red-600">{{ $error['message'] }}</td>
  109. </tr>
  110. @endforeach
  111. </tbody>
  112. </table>
  113. </div>
  114. @endif
  115. </div>
  116. @endif
  117. <!-- Plus 会员统计:默认收起,点按钮才异步加载 -->
  118. <v-member-plus-statistics>
  119. <!-- Shimmer -->
  120. <div class="mt-3.5 box-shadow rounded bg-white dark:bg-gray-900">
  121. <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
  122. <p class="text-base font-semibold text-gray-800 dark:text-white">
  123. @lang('member::app.member.stats.title')
  124. </p>
  125. <div class="shimmer h-[39px] w-[140px] rounded-md"></div>
  126. </div>
  127. </div>
  128. </v-member-plus-statistics>
  129. <x-admin::datagrid src="{{ route('admin.member.index') }}"></x-admin::datagrid>
  130. @pushOnce('scripts')
  131. <script
  132. type="module"
  133. src="{{ bagisto_asset('js/chart.js') }}"
  134. >
  135. </script>
  136. <script
  137. type="text/x-template"
  138. id="v-member-plus-statistics-template"
  139. >
  140. <div class="mt-3.5 box-shadow rounded bg-white dark:bg-gray-900">
  141. <!-- 标题 + 展开按钮 -->
  142. <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
  143. <div>
  144. <p class="text-base font-semibold text-gray-800 dark:text-white">
  145. @lang('member::app.member.stats.title')
  146. </p>
  147. <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
  148. @lang('member::app.member.stats.hint')
  149. </p>
  150. </div>
  151. <button
  152. type="button"
  153. class="secondary-button"
  154. @click="toggle()"
  155. >
  156. <span class="flex items-center gap-x-2.5">
  157. <span v-if="isOpen">@lang('member::app.member.stats.hide')</span>
  158. <span v-else>@lang('member::app.member.stats.show')</span>
  159. <span
  160. class="text-2xl text-gray-400"
  161. :class="isOpen ? 'icon-sort-up' : 'icon-sort-down'"
  162. ></span>
  163. </span>
  164. </button>
  165. </div>
  166. <!-- 统计内容 -->
  167. <div
  168. v-if="isOpen"
  169. class="border-t px-4 py-4 dark:border-gray-800"
  170. >
  171. <!-- 时间区间 -->
  172. <div class="flex flex-wrap items-end gap-x-2.5 gap-y-2">
  173. <button
  174. type="button"
  175. class="secondary-button"
  176. @click="applyQuickRange(7)"
  177. >
  178. @lang('member::app.member.stats.range-7')
  179. </button>
  180. <button
  181. type="button"
  182. class="secondary-button"
  183. @click="applyQuickRange(30)"
  184. >
  185. @lang('member::app.member.stats.range-30')
  186. </button>
  187. <button
  188. type="button"
  189. class="secondary-button"
  190. @click="applyQuickRange(90)"
  191. >
  192. @lang('member::app.member.stats.range-90')
  193. </button>
  194. <button
  195. type="button"
  196. class="secondary-button"
  197. @click="applyQuickRange(365)"
  198. >
  199. @lang('member::app.member.stats.range-365')
  200. </button>
  201. <x-admin::flat-picker.date class="!w-[140px]" ::allow-input="false">
  202. <input
  203. 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"
  204. v-model="filters.start"
  205. placeholder="@lang('member::app.member.stats.start-date')"
  206. />
  207. </x-admin::flat-picker.date>
  208. <x-admin::flat-picker.date class="!w-[140px]" ::allow-input="false">
  209. <input
  210. 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"
  211. v-model="filters.end"
  212. placeholder="@lang('member::app.member.stats.end-date')"
  213. />
  214. </x-admin::flat-picker.date>
  215. </div>
  216. <!-- 概览 -->
  217. <div class="mt-4 grid grid-cols-2 gap-4">
  218. <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
  219. <p class="text-xs text-gray-500 dark:text-gray-400">
  220. @lang('member::app.member.stats.active')
  221. </p>
  222. <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
  223. @{{ summary.active ?? 0 }}
  224. </p>
  225. </div>
  226. <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
  227. <p class="text-xs text-gray-500 dark:text-gray-400">
  228. @lang('member::app.member.stats.expired-total')
  229. </p>
  230. <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
  231. @{{ summary.expired_total ?? 0 }}
  232. </p>
  233. </div>
  234. <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
  235. <p class="text-xs text-gray-500 dark:text-gray-400">
  236. @lang('member::app.member.stats.new-total')
  237. </p>
  238. <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
  239. @{{ summary.new ?? 0 }}
  240. </p>
  241. </div>
  242. <div class="rounded-md bg-gray-50 p-4 dark:bg-gray-800">
  243. <p class="text-xs text-gray-500 dark:text-gray-400">
  244. @lang('member::app.member.stats.expired')
  245. </p>
  246. <p class="mt-1 text-lg font-bold leading-none text-gray-800 dark:text-white">
  247. @{{ summary.expired ?? 0 }}
  248. </p>
  249. </div>
  250. </div>
  251. <!-- 图例 -->
  252. <div class="mt-4 flex flex-wrap justify-center gap-5">
  253. <div class="flex items-center gap-1">
  254. <span class="h-3.5 w-3.5 rounded-md" style="background-color: #598de6"></span>
  255. <p class="text-xs text-gray-600 dark:text-gray-300">
  256. @lang('member::app.member.stats.series-new')
  257. </p>
  258. </div>
  259. <div class="flex items-center gap-1">
  260. <span class="h-3.5 w-3.5 rounded-md" style="background-color: #f87171"></span>
  261. <p class="text-xs text-gray-600 dark:text-gray-300">
  262. @lang('member::app.member.stats.series-expired')
  263. </p>
  264. </div>
  265. </div>
  266. <!-- 图表 -->
  267. <template v-if="isLoading">
  268. <div class="shimmer mt-4 h-[180px] w-full rounded-md"></div>
  269. </template>
  270. <template v-else>
  271. <x-admin::charts.bar
  272. ::key="chartVersion"
  273. ::labels="labels"
  274. ::datasets="datasets"
  275. ::aspect-ratio="3"
  276. />
  277. <p
  278. v-if="isEmpty"
  279. class="mt-2 text-center text-xs text-gray-500 dark:text-gray-400"
  280. >
  281. @lang('member::app.member.stats.empty')
  282. </p>
  283. </template>
  284. </div>
  285. </div>
  286. </script>
  287. <script type="module">
  288. app.component('v-member-plus-statistics', {
  289. template: '#v-member-plus-statistics-template',
  290. data() {
  291. return {
  292. // 默认收起:不请求统计接口,等用户点按钮
  293. isOpen: false,
  294. report: {
  295. labels: [],
  296. new: [],
  297. expired: [],
  298. summary: {},
  299. },
  300. isLoading: true,
  301. // 每次取数后 +1,用来强制图表重建(图表组件只在 mounted 时绘制)
  302. chartVersion: 0,
  303. today: "{{ now()->format('Y-m-d') }}",
  304. filters: {
  305. start: "{{ now()->subDays(29)->format('Y-m-d') }}",
  306. end: "{{ now()->format('Y-m-d') }}",
  307. },
  308. }
  309. },
  310. computed: {
  311. labels() {
  312. return this.report.labels ?? [];
  313. },
  314. summary() {
  315. return this.report.summary ?? {};
  316. },
  317. datasets() {
  318. return [{
  319. label: "{{ trans('member::app.member.stats.series-new') }}",
  320. data: this.report.new ?? [],
  321. backgroundColor: '#598de6',
  322. barThickness: 8,
  323. }, {
  324. label: "{{ trans('member::app.member.stats.series-expired') }}",
  325. data: this.report.expired ?? [],
  326. backgroundColor: '#f87171',
  327. barThickness: 8,
  328. }];
  329. },
  330. isEmpty() {
  331. if (this.isLoading) {
  332. return false;
  333. }
  334. return ! this.summary.new && ! this.summary.expired;
  335. },
  336. },
  337. watch: {
  338. filters: {
  339. handler() {
  340. this.getStats();
  341. },
  342. deep: true,
  343. },
  344. },
  345. methods: {
  346. toggle() {
  347. this.isOpen = ! this.isOpen;
  348. if (this.isOpen) {
  349. this.getStats();
  350. }
  351. },
  352. getStats() {
  353. this.isLoading = true;
  354. this.$axios.get("{{ route('admin.member.statistics') }}", {
  355. params: this.filters
  356. })
  357. .then(response => {
  358. this.report = response.data;
  359. this.isLoading = false;
  360. this.chartVersion++;
  361. })
  362. .catch(error => {
  363. this.isLoading = false;
  364. let message = error.response?.data?.message;
  365. if (message) {
  366. this.$emitter.emit('add-flash', { type: 'error', message });
  367. }
  368. });
  369. },
  370. applyQuickRange(days) {
  371. this.filters = {
  372. start: this.shiftDate(this.today, -(days - 1)),
  373. end: this.today,
  374. };
  375. },
  376. shiftDate(dateString, offsetDays) {
  377. let date = new Date(dateString + 'T00:00:00');
  378. date.setDate(date.getDate() + offsetDays);
  379. let month = String(date.getMonth() + 1).padStart(2, '0');
  380. let day = String(date.getDate()).padStart(2, '0');
  381. return `${date.getFullYear()}-${month}-${day}`;
  382. },
  383. },
  384. });
  385. </script>
  386. @endPushOnce
  387. </x-admin::layouts>