QrCodeManageView.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. <script setup>
  2. import {
  3. generateQrCodes,
  4. queryQrCodes,
  5. downloadQrCodesByDate,
  6. getQrCodeScanRecords
  7. } from '@/services/api'
  8. import { useDateFormat } from '@vueuse/core'
  9. import Button from 'primevue/button'
  10. import Column from 'primevue/column'
  11. import DataTable from 'primevue/datatable'
  12. import Dialog from 'primevue/dialog'
  13. import Select from 'primevue/select'
  14. import FloatLabel from 'primevue/floatlabel'
  15. import IconField from 'primevue/iconfield'
  16. import InputIcon from 'primevue/inputicon'
  17. import InputText from 'primevue/inputtext'
  18. import InputNumber from 'primevue/inputnumber'
  19. import DatePicker from 'primevue/datepicker'
  20. import Tag from 'primevue/tag'
  21. import { useToast } from 'primevue/usetoast'
  22. import { computed, onMounted, ref } from 'vue'
  23. const toast = useToast()
  24. // 表格数据
  25. const tableData = ref({
  26. content: [],
  27. metadata: {
  28. page: 0,
  29. size: 20,
  30. total: 0
  31. }
  32. })
  33. // 筛选条件
  34. const filters = ref({
  35. qrType: null,
  36. isActivated: null,
  37. startDate: null,
  38. endDate: null,
  39. search: ''
  40. })
  41. // 二维码类型选项
  42. const qrTypeOptions = [
  43. { label: '全部', value: null },
  44. { label: '人员', value: 'person' },
  45. { label: '宠物|物品', value: 'pet' }
  46. ]
  47. // 激活状态选项
  48. const activatedOptions = [
  49. { label: '全部', value: null },
  50. { label: '已激活', value: true },
  51. { label: '未激活', value: false }
  52. ]
  53. // 生成二维码对话框
  54. const generateDialog = ref(false)
  55. const generateForm = ref({
  56. qrType: 'person',
  57. quantity: 10
  58. })
  59. const generateLoading = ref(false)
  60. const generatedCodes = ref([])
  61. // 扫描记录对话框
  62. const scanRecordDialog = ref(false)
  63. const scanRecords = ref([])
  64. const selectedQrCode = ref(null)
  65. // 批量下载对话框
  66. const batchDownloadDialog = ref(false)
  67. const downloadDate = ref(null)
  68. // 获取二维码类型名称
  69. const getQrTypeName = (type) => {
  70. return type === 'person' ? '人员' : '宠物|物品'
  71. }
  72. // 获取激活状态标签
  73. const getActivatedTag = (isActivated) => {
  74. return isActivated
  75. ? { severity: 'success', label: '已激活' }
  76. : { severity: 'secondary', label: '未激活' }
  77. }
  78. // 格式化日期
  79. const formatDate = (date) => {
  80. if (!date) return '-'
  81. return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
  82. }
  83. const formatDateShort = (date) => {
  84. if (!date) return '-'
  85. return useDateFormat(new Date(date), 'YYYY-MM-DD').value
  86. }
  87. // 获取数据
  88. const fetchData = async () => {
  89. try {
  90. const params = {
  91. page: tableData.value.metadata.page,
  92. pageSize: tableData.value.metadata.size
  93. }
  94. if (filters.value.qrType) params.qrType = filters.value.qrType
  95. if (filters.value.isActivated !== null) params.isActivated = filters.value.isActivated
  96. if (filters.value.startDate)
  97. params.startDate = useDateFormat(filters.value.startDate, 'YYYY-MM-DD').value
  98. if (filters.value.endDate) params.endDate = useDateFormat(filters.value.endDate, 'YYYY-MM-DD').value
  99. if (filters.value.search?.trim()) params.search = filters.value.search.trim()
  100. const response = await queryQrCodes(params)
  101. tableData.value.content = response.content || []
  102. tableData.value.metadata = {
  103. page: response.metadata?.page ?? tableData.value.metadata.page,
  104. size: response.metadata?.size ?? tableData.value.metadata.size,
  105. total: response.metadata?.total ?? 0
  106. }
  107. } catch (error) {
  108. toast.add({
  109. severity: 'error',
  110. summary: '错误',
  111. detail: error.message || '获取数据失败',
  112. life: 3000
  113. })
  114. }
  115. }
  116. // 分页
  117. const handlePageChange = (event) => {
  118. tableData.value.metadata.page = event.page
  119. tableData.value.metadata.size = event.rows
  120. fetchData()
  121. }
  122. // 重置筛选并刷新
  123. const resetAndRefresh = () => {
  124. filters.value = {
  125. qrType: null,
  126. isActivated: null,
  127. startDate: null,
  128. endDate: null,
  129. search: ''
  130. }
  131. tableData.value.metadata.page = 0
  132. fetchData()
  133. }
  134. // 打开生成对话框
  135. const openGenerateDialog = () => {
  136. generateForm.value = {
  137. qrType: 'person',
  138. quantity: 10
  139. }
  140. generatedCodes.value = []
  141. generateDialog.value = true
  142. }
  143. // 打开批量下载对话框
  144. const openBatchDownloadDialog = () => {
  145. downloadDate.value = null
  146. batchDownloadDialog.value = true
  147. }
  148. // 确认批量下载
  149. const handleBatchDownload = async () => {
  150. if (!downloadDate.value) {
  151. toast.add({
  152. severity: 'warn',
  153. summary: '警告',
  154. detail: '请选择下载日期',
  155. life: 3000
  156. })
  157. return
  158. }
  159. await downloadByDate(downloadDate.value)
  160. batchDownloadDialog.value = false
  161. }
  162. // 生成二维码
  163. const handleGenerate = async () => {
  164. if (generateForm.value.quantity < 1 || generateForm.value.quantity > 1000) {
  165. toast.add({
  166. severity: 'warn',
  167. summary: '警告',
  168. detail: '生成数量必须在1-1000之间',
  169. life: 3000
  170. })
  171. return
  172. }
  173. generateLoading.value = true
  174. try {
  175. const response = await generateQrCodes(generateForm.value.qrType, generateForm.value.quantity)
  176. generatedCodes.value = response.data
  177. toast.add({
  178. severity: 'success',
  179. summary: '成功',
  180. detail: `成功生成 ${response.data.length} 个二维码`,
  181. life: 3000
  182. })
  183. fetchData()
  184. } catch (error) {
  185. toast.add({
  186. severity: 'error',
  187. summary: '错误',
  188. detail: error.message || '生成二维码失败',
  189. life: 3000
  190. })
  191. } finally {
  192. generateLoading.value = false
  193. }
  194. }
  195. // 导出生成的二维码
  196. const exportGeneratedCodes = () => {
  197. if (generatedCodes.value.length === 0) return
  198. const csvContent = [
  199. ['二维码编号', '维护码', '类型', '生成时间'].join(','),
  200. ...generatedCodes.value.map((code) =>
  201. [
  202. code.qrCode,
  203. code.maintenanceCode,
  204. getQrTypeName(generateForm.value.qrType),
  205. formatDate(new Date())
  206. ].join(',')
  207. )
  208. ].join('\n')
  209. const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
  210. const link = document.createElement('a')
  211. link.href = URL.createObjectURL(blob)
  212. link.download = `二维码列表_${formatDateShort(new Date())}.csv`
  213. link.click()
  214. }
  215. // 按日期下载二维码
  216. const downloadByDate = async (date) => {
  217. try {
  218. const dateStr = formatDateShort(date)
  219. const blob = await downloadQrCodesByDate(dateStr)
  220. const link = document.createElement('a')
  221. link.href = URL.createObjectURL(blob)
  222. link.download = `二维码_${dateStr}.csv`
  223. link.click()
  224. toast.add({
  225. severity: 'success',
  226. summary: '成功',
  227. detail: '下载成功',
  228. life: 3000
  229. })
  230. } catch (error) {
  231. toast.add({
  232. severity: 'error',
  233. summary: '错误',
  234. detail: error.message || '下载失败',
  235. life: 3000
  236. })
  237. }
  238. }
  239. // 查看扫描记录
  240. const viewScanRecords = async (qrCode) => {
  241. try {
  242. selectedQrCode.value = qrCode
  243. const response = await getQrCodeScanRecords(qrCode.qrCode, 20)
  244. scanRecords.value = response.data
  245. scanRecordDialog.value = true
  246. } catch (error) {
  247. toast.add({
  248. severity: 'error',
  249. summary: '错误',
  250. detail: error.message || '获取扫描记录失败',
  251. life: 3000
  252. })
  253. }
  254. }
  255. // 复制到剪贴板
  256. const copyToClipboard = (text) => {
  257. navigator.clipboard.writeText(text).then(() => {
  258. toast.add({
  259. severity: 'success',
  260. summary: '成功',
  261. detail: '已复制到剪贴板',
  262. life: 2000
  263. })
  264. })
  265. }
  266. onMounted(() => {
  267. fetchData()
  268. })
  269. </script>
  270. <template>
  271. <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
  272. <!-- 数据表格 -->
  273. <DataTable :value="tableData.content" :paginator="true"
  274. paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
  275. currentPageReportTemplate="{totalRecords} 条记录" :rows="tableData.metadata.size"
  276. :rowsPerPageOptions="[10, 20, 50, 100]" :totalRecords="tableData.metadata.total" @page="handlePageChange" lazy
  277. scrollable>
  278. <template #header>
  279. <div class="flex flex-wrap items-center gap-2">
  280. <!-- 筛选条件 - 左侧 -->
  281. <IconField>
  282. <InputIcon>
  283. <i class="pi pi-search" />
  284. </InputIcon>
  285. <InputText v-model="filters.search" placeholder="二维码编号" size="small" class="w-35" />
  286. </IconField>
  287. <Select v-model="filters.qrType" :options="qrTypeOptions" optionLabel="label" optionValue="value"
  288. placeholder="类型" class="w-30" size="small" />
  289. <Select v-model="filters.isActivated" :options="activatedOptions" optionLabel="label" optionValue="value"
  290. placeholder="激活状态" class="w-30" size="small" />
  291. <DatePicker v-model="filters.startDate" placeholder="开始日期" dateFormat="yy-mm-dd" class="w-40" size="small" />
  292. <DatePicker v-model="filters.endDate" placeholder="结束日期" dateFormat="yy-mm-dd" class="w-40" size="small" />
  293. <Button icon="pi pi-search" @click="fetchData" label="查询" size="small" />
  294. <Button icon="pi pi-refresh" @click="resetAndRefresh" label="刷新" size="small" />
  295. <Button icon="pi pi-plus" @click="openGenerateDialog" label="生成二维码" severity="info" size="small" />
  296. <Button icon="pi pi-download" @click="openBatchDownloadDialog" label="批量下载二维码" severity="danger"
  297. size="small" />
  298. </div>
  299. </template>
  300. <Column field="qrCode" header="二维码编号" style="min-width: 200px">
  301. <template #body="slotProps">
  302. <div class="flex items-center gap-2">
  303. <code class="text-sm">{{ slotProps.data.qrCode }}</code>
  304. <Button icon="pi pi-copy" size="small" text rounded @click="copyToClipboard(slotProps.data.qrCode)" />
  305. </div>
  306. </template>
  307. </Column>
  308. <Column field="qrType" header="类型" style="min-width: 120px">
  309. <template #body="slotProps">
  310. <Tag :value="getQrTypeName(slotProps.data.qrType)"
  311. :severity="slotProps.data.qrType === 'person' ? 'info' : 'warn'" />
  312. </template>
  313. </Column>
  314. <Column field="isActivated" header="状态" style="min-width: 100px">
  315. <template #body="slotProps">
  316. <Tag :value="getActivatedTag(slotProps.data.isActivated).label"
  317. :severity="getActivatedTag(slotProps.data.isActivated).severity" />
  318. </template>
  319. </Column>
  320. <Column field="scanCount" header="扫描次数" style="min-width: 100px">
  321. <template #body="slotProps">
  322. <span class="font-semibold">{{ slotProps.data.scanCount || 0 }}</span>
  323. </template>
  324. </Column>
  325. <Column field="createdAt" header="生成时间" style="min-width: 180px">
  326. <template #body="slotProps">
  327. {{ formatDate(slotProps.data.createdAt) }}
  328. </template>
  329. </Column>
  330. <Column header="操作" style="min-width: 150px">
  331. <template #body="slotProps">
  332. <div class="flex gap-1">
  333. <Button icon="pi pi-chart-line" label="扫描记录" size="small" text style="white-space: nowrap"
  334. @click="viewScanRecords(slotProps.data)" />
  335. <Button icon="pi pi-download" severity="danger" size="small" text rounded v-tooltip.top="'下载'"
  336. @click="downloadByDate(slotProps.data.createdAt)" />
  337. </div>
  338. </template>
  339. </Column>
  340. </DataTable>
  341. <!-- 生成二维码对话框 -->
  342. <Dialog v-model:visible="generateDialog" :modal="true" header="生成二维码" :style="{ width: '550px' }" position="center">
  343. <div class="space-y-4">
  344. <div class="field" style="margin-top: 10px;">
  345. <FloatLabel variant="on">
  346. <Select id="qrType" v-model="generateForm.qrType" :options="[
  347. { label: '人员', value: 'person' },
  348. { label: '宠物|物品', value: 'pet' }
  349. ]" optionLabel="label" optionValue="value" fluid />
  350. <label for="qrType">二维码类型</label>
  351. </FloatLabel>
  352. </div>
  353. <div class="field" style="margin-top: 10px;">
  354. <FloatLabel variant="on">
  355. <InputNumber id="quantity" v-model="generateForm.quantity" :min="1" :max="1000" fluid />
  356. <label for="quantity">生成数量 (1-1000)</label>
  357. </FloatLabel>
  358. </div>
  359. <!-- 已生成的二维码列表 -->
  360. <div v-if="generatedCodes.length > 0" class="mt-4">
  361. <div class="flex justify-between items-center mb-2">
  362. <h4 class="font-semibold">生成结果 ({{ generatedCodes.length }} 个)</h4>
  363. <Button icon="pi pi-download" label="导出CSV" size="small" @click="exportGeneratedCodes" />
  364. </div>
  365. <div class="max-h-60 overflow-y-auto border rounded p-2">
  366. <div v-for="(code, index) in generatedCodes" :key="index"
  367. class="flex justify-between items-center py-2 border-b last:border-b-0">
  368. <div>
  369. <div class="text-sm font-mono">{{ code.qrCode }}</div>
  370. <div class="text-xs text-gray-500">维护码: {{ code.maintenanceCode }}</div>
  371. </div>
  372. <Button icon="pi pi-copy" size="small" text
  373. @click="copyToClipboard(`${code.qrCode},${code.maintenanceCode}`)" />
  374. </div>
  375. </div>
  376. </div>
  377. </div>
  378. <template #footer>
  379. <Button label="取消" severity="secondary" @click="generateDialog = false" :disabled="generateLoading" />
  380. <Button label="生成" @click="handleGenerate" :loading="generateLoading" />
  381. </template>
  382. </Dialog>
  383. <!-- 扫描记录对话框 -->
  384. <Dialog v-model:visible="scanRecordDialog" :modal="true" header="扫描记录" :style="{ width: '800px' }"
  385. position="center">
  386. <div v-if="selectedQrCode" class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded">
  387. <div class="text-sm text-gray-600 dark:text-gray-400">二维码编号</div>
  388. <div class="font-mono font-semibold">{{ selectedQrCode.qrCode }}</div>
  389. <div class="text-sm text-gray-600 dark:text-gray-400 mt-2">总扫描次数</div>
  390. <div class="font-semibold text-lg">{{ selectedQrCode.scanCount || 0 }} 次</div>
  391. </div>
  392. <DataTable :value="scanRecords" scrollable scrollHeight="400px">
  393. <Column field="scanTime" header="扫描时间" style="min-width: 180px">
  394. <template #body="slotProps">
  395. {{ formatDate(slotProps.data.scanTime) }}
  396. </template>
  397. </Column>
  398. <Column field="address" header="地址" style="min-width: 200px">
  399. <template #body="slotProps">
  400. {{ slotProps.data.address || '-' }}
  401. </template>
  402. </Column>
  403. <Column field="latitude" header="位置" style="min-width: 150px">
  404. <template #body="slotProps">
  405. <a v-if="slotProps.data.latitude && slotProps.data.longitude"
  406. :href="`https://www.google.com/maps?q=${slotProps.data.latitude},${slotProps.data.longitude}`"
  407. target="_blank" class="text-blue-600 hover:underline">
  408. 📍 查看地图
  409. </a>
  410. <span v-else>-</span>
  411. </template>
  412. </Column>
  413. <Column field="ipAddress" header="IP地址" style="min-width: 150px">
  414. <template #body="slotProps">
  415. {{ slotProps.data.ipAddress || '-' }}
  416. </template>
  417. </Column>
  418. </DataTable>
  419. <template #footer>
  420. <Button label="关闭" @click="scanRecordDialog = false" />
  421. </template>
  422. </Dialog>
  423. <!-- 批量下载二维码对话框 -->
  424. <Dialog v-model:visible="batchDownloadDialog" :modal="true" header="批量下载二维码" :style="{ width: '450px' }"
  425. position="center">
  426. <div class="space-y-4">
  427. <div class="field" style="margin-top: 10px;">
  428. <FloatLabel variant="on">
  429. <DatePicker id="downloadDate" v-model="downloadDate" dateFormat="yy-mm-dd" fluid />
  430. <label for="downloadDate">下载日期</label>
  431. </FloatLabel>
  432. </div>
  433. </div>
  434. <template #footer>
  435. <Button label="取消" severity="secondary" @click="batchDownloadDialog = false" />
  436. <Button label="下载" @click="handleBatchDownload" />
  437. </template>
  438. </Dialog>
  439. </div>
  440. </template>