QrCodeManageView.vue 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981
  1. <script setup>
  2. import {
  3. generateQrCodes,
  4. queryQrCodes,
  5. getQrCodeScanRecords,
  6. getQrCodeInfo,
  7. resetMaintenanceCode
  8. } from '@/services/api'
  9. import { getQrCodeTypeConfig } from '@/enums'
  10. import { useDateFormat } from '@vueuse/core'
  11. import Button from 'primevue/button'
  12. import Column from 'primevue/column'
  13. import DataTable from 'primevue/datatable'
  14. import Dialog from 'primevue/dialog'
  15. import Select from 'primevue/select'
  16. import FloatLabel from 'primevue/floatlabel'
  17. import IconField from 'primevue/iconfield'
  18. import InputIcon from 'primevue/inputicon'
  19. import InputText from 'primevue/inputtext'
  20. import InputNumber from 'primevue/inputnumber'
  21. import DatePicker from 'primevue/datepicker'
  22. import Tag from 'primevue/tag'
  23. import { useToast } from 'primevue/usetoast'
  24. import { onMounted, ref } from 'vue'
  25. import QRCodeUtil from '@/utils/qrcode'
  26. const toast = useToast()
  27. // 表格数据
  28. const tableData = ref({
  29. content: [],
  30. metadata: {
  31. page: 0,
  32. size: 20,
  33. total: 0
  34. }
  35. })
  36. // 筛选条件
  37. const filters = ref({
  38. qrType: null,
  39. isActivated: null,
  40. startDate: null,
  41. endDate: null,
  42. search: ''
  43. })
  44. // 二维码类型选项
  45. const qrTypeOptions = [
  46. { label: '全部', value: null },
  47. { label: '人员', value: 'person' },
  48. { label: '宠物', value: 'pet' },
  49. { label: '物品', value: 'goods' },
  50. { label: '链接', value: 'link' }
  51. ]
  52. // 激活状态选项
  53. const activatedOptions = [
  54. { label: '全部', value: null },
  55. { label: '已激活', value: true },
  56. { label: '未激活', value: false }
  57. ]
  58. // 生成二维码对话框
  59. const generateDialog = ref(false)
  60. const generateForm = ref({
  61. qrType: 'person',
  62. quantity: 10
  63. })
  64. const generateLoading = ref(false)
  65. const generatedCodes = ref([])
  66. // 扫描记录对话框
  67. const scanRecordDialog = ref(false)
  68. const scanRecords = ref([])
  69. const selectedQrCode = ref(null)
  70. // 批量下载对话框
  71. const batchDownloadDialog = ref(false)
  72. const downloadDate = ref(null)
  73. const batchDownloadLoading = ref(false)
  74. // 详情对话框
  75. const detailDialog = ref(false)
  76. const detailLoading = ref(false)
  77. const qrCodeDetail = ref(null)
  78. // 修改维护码对话框
  79. const resetDialog = ref(false)
  80. const resetForm = ref({
  81. qrCode: '',
  82. maintenanceCode: '',
  83. codeLength: 10
  84. })
  85. const resetLoading = ref(false)
  86. // 展示二维码对话框
  87. const showQrCodeDialog = ref(false)
  88. const currentQrCodeUrl = ref('')
  89. const currentQrCodeNumber = ref('')
  90. const currentMaintenanceCode = ref('')
  91. const qrCodeImage = ref('')
  92. // 获取二维码类型配置(使用枚举)
  93. const getTypeConfig = (type) => {
  94. return getQrCodeTypeConfig(type)
  95. }
  96. // 获取激活状态标签
  97. const getActivatedTag = (isActivated) => {
  98. return isActivated
  99. ? { severity: 'success', label: '已激活' }
  100. : { severity: 'secondary', label: '未激活' }
  101. }
  102. // 格式化日期
  103. const formatDate = (date) => {
  104. if (!date) return '-'
  105. return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
  106. }
  107. const formatDateShort = (date) => {
  108. if (!date) return '-'
  109. return useDateFormat(new Date(date), 'YYYY-MM-DD').value
  110. }
  111. // 获取数据
  112. const fetchData = async () => {
  113. try {
  114. const params = {
  115. page: tableData.value.metadata.page,
  116. pageSize: tableData.value.metadata.size
  117. }
  118. if (filters.value.qrType) params.qrType = filters.value.qrType
  119. if (filters.value.isActivated !== null) params.isActivated = filters.value.isActivated
  120. if (filters.value.startDate)
  121. params.startDate = useDateFormat(filters.value.startDate, 'YYYY-MM-DD').value
  122. if (filters.value.endDate) params.endDate = useDateFormat(filters.value.endDate, 'YYYY-MM-DD').value
  123. if (filters.value.search?.trim()) params.qrCode = filters.value.search.trim()
  124. const response = await queryQrCodes(params)
  125. tableData.value.content = response.content || []
  126. tableData.value.metadata = {
  127. page: response.metadata?.page ?? tableData.value.metadata.page,
  128. size: response.metadata?.size ?? tableData.value.metadata.size,
  129. total: response.metadata?.total ?? 0
  130. }
  131. } catch (error) {
  132. toast.add({
  133. severity: 'error',
  134. summary: '错误',
  135. detail: error.message || '获取数据失败',
  136. life: 3000
  137. })
  138. }
  139. }
  140. // 分页
  141. const handlePageChange = (event) => {
  142. tableData.value.metadata.page = event.page
  143. tableData.value.metadata.size = event.rows
  144. fetchData()
  145. }
  146. // 重置筛选并刷新
  147. const resetAndRefresh = () => {
  148. filters.value = {
  149. qrType: null,
  150. isActivated: null,
  151. startDate: null,
  152. endDate: null,
  153. search: ''
  154. }
  155. tableData.value.metadata.page = 0
  156. fetchData()
  157. }
  158. // 打开生成对话框
  159. const openGenerateDialog = () => {
  160. generateForm.value = {
  161. qrType: 'person',
  162. quantity: 10
  163. }
  164. generatedCodes.value = []
  165. generateDialog.value = true
  166. }
  167. // 打开批量下载对话框
  168. const openBatchDownloadDialog = () => {
  169. downloadDate.value = null
  170. batchDownloadDialog.value = true
  171. }
  172. // 确认批量下载
  173. const handleBatchDownload = async () => {
  174. if (!downloadDate.value) {
  175. toast.add({
  176. severity: 'warn',
  177. summary: '警告',
  178. detail: '请选择下载日期',
  179. life: 3000
  180. })
  181. return
  182. }
  183. batchDownloadLoading.value = true
  184. try {
  185. const dateStr = formatDateShort(downloadDate.value)
  186. // 查询该日期的所有二维码
  187. const response = await queryQrCodes({
  188. startDate: dateStr,
  189. endDate: dateStr,
  190. page: 0,
  191. pageSize: 10000
  192. })
  193. if (!response.content || response.content.length === 0) {
  194. toast.add({
  195. severity: 'warn',
  196. summary: '警告',
  197. detail: '该日期没有二维码数据',
  198. life: 3000
  199. })
  200. return
  201. }
  202. // 准备二维码列表数据
  203. const qrCodeList = response.content.map(item => ({
  204. qrCode: item.qrCode,
  205. maintenanceCode: item.maintenanceCode || 'unknown'
  206. }))
  207. // 批量生成并下载为 ZIP
  208. await QRCodeUtil.downloadBatchQrCodesAsZip(qrCodeList, dateStr)
  209. toast.add({
  210. severity: 'success',
  211. summary: '成功',
  212. detail: `成功下载 ${qrCodeList.length} 个二维码`,
  213. life: 3000
  214. })
  215. batchDownloadDialog.value = false
  216. } catch (error) {
  217. toast.add({
  218. severity: 'error',
  219. summary: '错误',
  220. detail: error.message || '批量下载失败',
  221. life: 3000
  222. })
  223. } finally {
  224. batchDownloadLoading.value = false
  225. }
  226. }
  227. // 生成二维码
  228. const handleGenerate = async () => {
  229. if (generateForm.value.quantity < 1 || generateForm.value.quantity > 10000) {
  230. toast.add({
  231. severity: 'warn',
  232. summary: '警告',
  233. detail: '生成数量必须在1-10000之间',
  234. life: 3000
  235. })
  236. return
  237. }
  238. generateLoading.value = true
  239. try {
  240. const response = await generateQrCodes(generateForm.value.qrType, generateForm.value.quantity)
  241. toast.add({
  242. severity: 'success',
  243. summary: '成功',
  244. detail: `成功生成 ${response.data.length} 个二维码`,
  245. life: 3000
  246. })
  247. generateDialog.value = false
  248. fetchData()
  249. } catch (error) {
  250. toast.add({
  251. severity: 'error',
  252. summary: '错误',
  253. detail: error.message || '生成二维码失败',
  254. life: 3000
  255. })
  256. } finally {
  257. generateLoading.value = false
  258. }
  259. }
  260. // 查看扫描记录
  261. const viewScanRecords = async (qrCode) => {
  262. try {
  263. selectedQrCode.value = qrCode
  264. const response = await getQrCodeScanRecords(qrCode.qrCode, 10)
  265. scanRecords.value = response.records || []
  266. scanRecordDialog.value = true
  267. } catch (error) {
  268. toast.add({
  269. severity: 'error',
  270. summary: '错误',
  271. detail: error.message || '获取扫描记录失败',
  272. life: 3000
  273. })
  274. }
  275. }
  276. // 复制到剪贴板
  277. const copyToClipboard = async (text) => {
  278. try {
  279. // 优先使用现代 Clipboard API
  280. if (navigator.clipboard && window.isSecureContext) {
  281. await navigator.clipboard.writeText(text)
  282. toast.add({
  283. severity: 'success',
  284. summary: '成功',
  285. detail: '已复制到剪贴板',
  286. life: 2000
  287. })
  288. } else {
  289. // 降级方案:使用传统的 document.execCommand
  290. const textArea = document.createElement('textarea')
  291. textArea.value = text
  292. textArea.style.position = 'fixed'
  293. textArea.style.left = '-999999px'
  294. textArea.style.top = '-999999px'
  295. document.body.appendChild(textArea)
  296. textArea.focus()
  297. textArea.select()
  298. const successful = document.execCommand('copy')
  299. document.body.removeChild(textArea)
  300. if (successful) {
  301. toast.add({
  302. severity: 'success',
  303. summary: '成功',
  304. detail: '已复制到剪贴板',
  305. life: 2000
  306. })
  307. } else {
  308. throw new Error('复制失败')
  309. }
  310. }
  311. } catch (error) {
  312. toast.add({
  313. severity: 'error',
  314. summary: '错误',
  315. detail: '复制失败,请手动复制',
  316. life: 3000
  317. })
  318. }
  319. }
  320. // 查看详情
  321. const viewDetail = async (qrCode) => {
  322. try {
  323. detailDialog.value = true
  324. detailLoading.value = true
  325. qrCodeDetail.value = null
  326. const response = await getQrCodeInfo(qrCode.id)
  327. qrCodeDetail.value = response
  328. } catch (error) {
  329. detailDialog.value = false
  330. toast.add({
  331. severity: 'error',
  332. summary: '错误',
  333. detail: error.message || '获取详情失败',
  334. life: 3000
  335. })
  336. } finally {
  337. detailLoading.value = false
  338. }
  339. }
  340. // 打开修改维护码对话框
  341. const openResetDialog = (qrCode) => {
  342. resetForm.value = {
  343. qrCode: qrCode.qrCode,
  344. maintenanceCode: '',
  345. codeLength: 10
  346. }
  347. resetDialog.value = true
  348. }
  349. // 生成随机维护码(根据选择的位数)
  350. const generateRandomCode = () => {
  351. const length = resetForm.value.codeLength || 10
  352. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  353. let code = ''
  354. for (let i = 0; i < length; i++) {
  355. code += chars.charAt(Math.floor(Math.random() * chars.length))
  356. }
  357. resetForm.value.maintenanceCode = code
  358. }
  359. // 确认修改维护码
  360. const handleResetMaintenanceCode = async () => {
  361. if (!resetForm.value.maintenanceCode) {
  362. toast.add({
  363. severity: 'warn',
  364. summary: '警告',
  365. detail: '请输入维护码',
  366. life: 3000
  367. })
  368. return
  369. }
  370. const code = resetForm.value.maintenanceCode.trim()
  371. if (code.length < 8 || code.length > 20) {
  372. toast.add({
  373. severity: 'warn',
  374. summary: '警告',
  375. detail: '维护码长度必须在8-20位之间',
  376. life: 3000
  377. })
  378. return
  379. }
  380. if (!/^[A-Za-z0-9]+$/.test(code)) {
  381. toast.add({
  382. severity: 'warn',
  383. summary: '警告',
  384. detail: '维护码只能包含字母和数字',
  385. life: 3000
  386. })
  387. return
  388. }
  389. resetLoading.value = true
  390. try {
  391. await resetMaintenanceCode(resetForm.value.qrCode, code)
  392. toast.add({
  393. severity: 'success',
  394. summary: '成功',
  395. detail: '维护码修改成功',
  396. life: 3000
  397. })
  398. resetDialog.value = false
  399. fetchData()
  400. } catch (error) {
  401. toast.add({
  402. severity: 'error',
  403. summary: '错误',
  404. detail: error.message || '修改维护码失败',
  405. life: 3000
  406. })
  407. } finally {
  408. resetLoading.value = false
  409. }
  410. }
  411. // 展示二维码
  412. const showQrCode = async (qrCodeData) => {
  413. try {
  414. // 保存二维码编号和维护码
  415. const qrCode = typeof qrCodeData === 'string' ? qrCodeData : qrCodeData.qrCode
  416. const maintenanceCode = typeof qrCodeData === 'string' ? 'unknown' : (qrCodeData.maintenanceCode || 'unknown')
  417. currentQrCodeNumber.value = qrCode
  418. currentMaintenanceCode.value = maintenanceCode
  419. // 生成二维码 URL
  420. currentQrCodeUrl.value = QRCodeUtil.generateQrCodeUrl(qrCode)
  421. // 生成二维码图片
  422. qrCodeImage.value = await QRCodeUtil.generateQrCodeImage(qrCode)
  423. showQrCodeDialog.value = true
  424. } catch (error) {
  425. toast.add({
  426. severity: 'error',
  427. summary: '错误',
  428. detail: error.message || '生成二维码失败',
  429. life: 3000
  430. })
  431. }
  432. }
  433. // 下载当前二维码
  434. const downloadCurrentQrCode = () => {
  435. if (qrCodeImage.value && currentQrCodeNumber.value && currentMaintenanceCode.value) {
  436. QRCodeUtil.downloadQrCode(qrCodeImage.value, `${currentQrCodeNumber.value}_${currentMaintenanceCode.value}`)
  437. toast.add({
  438. severity: 'success',
  439. summary: '成功',
  440. detail: '二维码下载成功',
  441. life: 2000
  442. })
  443. }
  444. }
  445. onMounted(() => {
  446. fetchData()
  447. })
  448. </script>
  449. <template>
  450. <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
  451. <!-- 数据表格 -->
  452. <DataTable :value="tableData.content" :paginator="true"
  453. paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
  454. currentPageReportTemplate="{totalRecords} 条记录" :rows="tableData.metadata.size"
  455. :rowsPerPageOptions="[10, 20, 50, 100]" :totalRecords="tableData.metadata.total" @page="handlePageChange" lazy
  456. scrollable>
  457. <template #header>
  458. <div class="flex flex-wrap items-center gap-2">
  459. <!-- 筛选条件 - 左侧 -->
  460. <IconField>
  461. <InputIcon>
  462. <i class="pi pi-search" />
  463. </InputIcon>
  464. <InputText v-model="filters.search" placeholder="二维码编号" size="small" class="w-35" />
  465. </IconField>
  466. <Select v-model="filters.qrType" :options="qrTypeOptions" optionLabel="label" optionValue="value"
  467. placeholder="类型" class="w-30" size="small" />
  468. <Select v-model="filters.isActivated" :options="activatedOptions" optionLabel="label" optionValue="value"
  469. placeholder="激活状态" class="w-30" size="small" />
  470. <DatePicker v-model="filters.startDate" placeholder="开始日期" dateFormat="yy-mm-dd" class="w-40" size="small" />
  471. <DatePicker v-model="filters.endDate" placeholder="结束日期" dateFormat="yy-mm-dd" class="w-40" size="small" />
  472. <Button icon="pi pi-search" @click="fetchData" label="查询" size="small" />
  473. <Button icon="pi pi-refresh" @click="resetAndRefresh" label="刷新" size="small" />
  474. <Button icon="pi pi-plus" @click="openGenerateDialog" label="生成二维码" severity="info" size="small" />
  475. <Button icon="pi pi-download" @click="openBatchDownloadDialog" label="批量下载二维码" severity="danger"
  476. size="small" />
  477. </div>
  478. </template>
  479. <Column field="qrCode" header="二维码编号" style="min-width: 200px">
  480. <template #body="slotProps">
  481. <div class="flex items-center gap-2">
  482. <code class="text-sm">{{ slotProps.data.qrCode }}</code>
  483. <Button icon="pi pi-copy" size="small" text rounded @click="copyToClipboard(slotProps.data.qrCode)" />
  484. </div>
  485. </template>
  486. </Column>
  487. <Column field="maintenanceCode" header="维护码" style="min-width: 180px">
  488. <template #body="slotProps">
  489. <div class="flex items-center gap-2">
  490. <code class="text-sm">{{ slotProps.data.maintenanceCode || '-' }}</code>
  491. <Button v-if="slotProps.data.maintenanceCode" icon="pi pi-copy" size="small" text rounded
  492. @click="copyToClipboard(slotProps.data.maintenanceCode)" />
  493. </div>
  494. </template>
  495. </Column>
  496. <Column field="qrType" header="类型" style="min-width: 120px">
  497. <template #body="slotProps">
  498. <Tag :severity="getTypeConfig(slotProps.data.qrType).severity">
  499. <i :class="`pi ${getTypeConfig(slotProps.data.qrType).icon} mr-1`"></i>
  500. {{ getTypeConfig(slotProps.data.qrType).label }}
  501. </Tag>
  502. </template>
  503. </Column>
  504. <Column field="isActivated" header="状态" style="min-width: 100px">
  505. <template #body="slotProps">
  506. <Tag :value="getActivatedTag(slotProps.data.isActivated).label"
  507. :severity="getActivatedTag(slotProps.data.isActivated).severity" />
  508. </template>
  509. </Column>
  510. <Column field="scanCount" header="扫描次数" style="min-width: 100px"
  511. :pt="{ columnHeaderContent: { class: 'justify-center' } }">
  512. <template #body="slotProps">
  513. <div class="text-center">
  514. <span class="font-semibold">{{ slotProps.data.scanCount || 0 }}</span>
  515. </div>
  516. </template>
  517. </Column>
  518. <Column field="createdAt" header="生成时间" style="min-width: 180px">
  519. <template #body="slotProps">
  520. {{ formatDate(slotProps.data.createdAt) }}
  521. </template>
  522. </Column>
  523. <Column header="操作" style="min-width: 350px" :pt="{ columnHeaderContent: { class: 'justify-center' } }">
  524. <template #body="slotProps">
  525. <div class="flex gap-1 justify-center">
  526. <Button icon="pi pi-eye" severity="info" size="small" text rounded v-tooltip.top="'查看详情'"
  527. @click="viewDetail(slotProps.data)" />
  528. <Button icon="pi pi-qrcode" severity="success" size="small" text rounded v-tooltip.top="'展示二维码'"
  529. @click="showQrCode(slotProps.data)" />
  530. <Button icon="pi pi-chart-line" label="扫描记录" size="small" text style="white-space: nowrap"
  531. @click="viewScanRecords(slotProps.data)" />
  532. <Button icon="pi pi-key" label="修改维护码" severity="warn" size="small" text style="white-space: nowrap"
  533. @click="openResetDialog(slotProps.data)" />
  534. </div>
  535. </template>
  536. </Column>
  537. </DataTable>
  538. <!-- 生成二维码对话框 -->
  539. <Dialog v-model:visible="generateDialog" :modal="true" header="生成二维码" :style="{ width: '450px' }" position="center">
  540. <div class="space-y-4">
  541. <div class="field" style="margin-top: 10px;">
  542. <FloatLabel variant="on">
  543. <Select id="qrType" v-model="generateForm.qrType" :options="[
  544. { label: '人员', value: 'person' },
  545. { label: '宠物', value: 'pet' },
  546. { label: '物品', value: 'goods' },
  547. { label: '链接', value: 'link' }
  548. ]" optionLabel="label" optionValue="value" fluid />
  549. <label for="qrType">二维码类型</label>
  550. </FloatLabel>
  551. </div>
  552. <div class="field" style="margin-top: 10px;">
  553. <FloatLabel variant="on">
  554. <InputNumber id="quantity" v-model="generateForm.quantity" :min="1" :max="10000" fluid />
  555. <label for="quantity">生成数量 (1-10000)</label>
  556. </FloatLabel>
  557. </div>
  558. </div>
  559. <template #footer>
  560. <Button label="取消" severity="secondary" @click="generateDialog = false" :disabled="generateLoading" />
  561. <Button label="生成" @click="handleGenerate" :loading="generateLoading" />
  562. </template>
  563. </Dialog>
  564. <!-- 扫描记录对话框 -->
  565. <Dialog v-model:visible="scanRecordDialog" :modal="true" header="扫描记录" :style="{ width: '900px' }"
  566. position="center">
  567. <div v-if="selectedQrCode" class="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded">
  568. <div class="grid grid-cols-2 gap-4">
  569. <div>
  570. <div class="text-sm text-gray-600 dark:text-gray-400">二维码编号</div>
  571. <div class="font-mono font-semibold">{{ selectedQrCode.qrCode }}</div>
  572. </div>
  573. <div>
  574. <div class="text-sm text-gray-600 dark:text-gray-400">总扫描次数</div>
  575. <div class="font-semibold text-lg text-blue-600">{{ selectedQrCode.scanCount || 0 }} 次</div>
  576. </div>
  577. </div>
  578. </div>
  579. <div v-if="scanRecords.length === 0" class="text-center py-8 text-gray-500">
  580. <i class="pi pi-inbox text-4xl mb-2"></i>
  581. <div>暂无扫描记录</div>
  582. </div>
  583. <DataTable v-else :value="scanRecords" scrollable scrollHeight="450px" striped>
  584. <template #header>
  585. <div class="text-sm text-gray-600">最近 10 条扫描记录</div>
  586. </template>
  587. <Column field="scanTime" header="扫描时间" style="min-width: 180px">
  588. <template #body="slotProps">
  589. <div class="flex items-center gap-2">
  590. <i class="pi pi-clock text-gray-400"></i>
  591. <span>{{ formatDate(slotProps.data.scanTime) }}</span>
  592. </div>
  593. </template>
  594. </Column>
  595. <Column field="ipAddress" header="IP地址" style="min-width: 140px">
  596. <template #body="slotProps">
  597. <div class="flex items-center gap-2">
  598. <i class="pi pi-globe text-gray-400"></i>
  599. <code class="text-xs">{{ slotProps.data.ipAddress || '-' }}</code>
  600. </div>
  601. </template>
  602. </Column>
  603. <Column field="address" header="地址" style="min-width: 200px">
  604. <template #body="slotProps">
  605. <div class="flex items-center gap-2">
  606. <i class="pi pi-map-marker text-gray-400"></i>
  607. <span class="text-sm">{{ slotProps.data.address || '未获取' }}</span>
  608. </div>
  609. </template>
  610. </Column>
  611. <Column field="location" header="位置坐标" style="min-width: 150px">
  612. <template #body="slotProps">
  613. <a v-if="slotProps.data.latitude && slotProps.data.longitude"
  614. :href="`https://www.google.com/maps?q=${slotProps.data.latitude},${slotProps.data.longitude}`"
  615. target="_blank" class="text-blue-600 hover:underline flex items-center gap-1">
  616. <i class="pi pi-external-link"></i>
  617. 查看地图
  618. </a>
  619. <span v-else class="text-gray-400 text-sm">未获取</span>
  620. </template>
  621. </Column>
  622. <Column field="userAgent" header="设备信息" style="min-width: 250px">
  623. <template #body="slotProps">
  624. <div class="text-xs text-gray-600 truncate" :title="slotProps.data.userAgent">
  625. <i class="pi pi-mobile text-gray-400 mr-1"></i>
  626. {{ slotProps.data.userAgent || '-' }}
  627. </div>
  628. </template>
  629. </Column>
  630. </DataTable>
  631. <template #footer>
  632. <Button label="关闭" @click="scanRecordDialog = false" />
  633. </template>
  634. </Dialog>
  635. <!-- 批量下载二维码对话框 -->
  636. <Dialog v-model:visible="batchDownloadDialog" :modal="true" header="批量下载二维码" :style="{ width: '500px' }"
  637. position="center">
  638. <div class="space-y-4">
  639. <div class="field" style="margin-top: 10px;">
  640. <FloatLabel variant="on">
  641. <DatePicker id="downloadDate" v-model="downloadDate" dateFormat="yy-mm-dd" fluid
  642. :disabled="batchDownloadLoading" />
  643. <label for="downloadDate">选择日期</label>
  644. </FloatLabel>
  645. </div>
  646. <div class="text-sm text-gray-600 bg-blue-50 dark:bg-blue-900/20 p-3 rounded">
  647. <i class="pi pi-info-circle mr-2"></i>
  648. 将下载该日期生成的所有二维码,打包为 ZIP 文件
  649. </div>
  650. </div>
  651. <template #footer>
  652. <Button label="取消" severity="secondary" @click="batchDownloadDialog = false" :disabled="batchDownloadLoading" />
  653. <Button icon="pi pi-download" label="下载" @click="handleBatchDownload" :loading="batchDownloadLoading" />
  654. </template>
  655. </Dialog>
  656. <!-- 修改维护码对话框 -->
  657. <Dialog v-model:visible="resetDialog" :modal="true" header="修改维护码" :style="{ width: '500px' }" position="center">
  658. <div class="space-y-4">
  659. <div class="field" style="margin-top: 10px;">
  660. <label class="block mb-2 text-sm font-medium">二维码编号</label>
  661. <InputText :value="resetForm.qrCode" disabled fluid />
  662. </div>
  663. <div class="field" style="margin-top: 20px;">
  664. <label class="block mb-2 text-sm font-medium">新维护码 *</label>
  665. <div class="flex gap-2 items-stretch">
  666. <InputText id="maintenanceCode" v-model="resetForm.maintenanceCode" placeholder="8-20位字母和数字"
  667. class="flex-1" />
  668. <InputNumber v-model="resetForm.codeLength" :min="8" :max="20" showButtons :step="1"
  669. :inputStyle="{ minWidth: '45px', textAlign: 'center' }" style="width: 100px" v-tooltip.top="'位数'" />
  670. <Button icon="pi pi-refresh" severity="secondary" @click="generateRandomCode" v-tooltip.top="'随机生成'"
  671. style="width: 2.8rem; padding: 0" />
  672. </div>
  673. <small class="text-gray-500 mt-1 block">维护码长度为8-20位,只能包含字母和数字</small>
  674. </div>
  675. </div>
  676. <template #footer>
  677. <Button label="取消" severity="secondary" @click="resetDialog = false" :disabled="resetLoading" />
  678. <Button label="确认修改" @click="handleResetMaintenanceCode" :loading="resetLoading" />
  679. </template>
  680. </Dialog>
  681. <!-- 展示二维码对话框 -->
  682. <Dialog v-model:visible="showQrCodeDialog" :modal="true" header="二维码展示" :style="{ width: '450px' }"
  683. position="center">
  684. <div class="flex flex-col items-center gap-4 py-4">
  685. <!-- 二维码图片 -->
  686. <div v-if="qrCodeImage" class="bg-white p-4 rounded-lg shadow-md">
  687. <img :src="qrCodeImage" alt="二维码" class="w-[300px] h-[300px]" />
  688. </div>
  689. <div
  690. class="font-mono text-xs break-all bg-gray-100 dark:bg-gray-800 p-3 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
  691. @click="copyToClipboard(currentQrCodeUrl)" v-tooltip.top="'点击复制'">
  692. {{ currentQrCodeUrl }}
  693. </div>
  694. <div
  695. v-if="currentMaintenanceCode && currentMaintenanceCode !== 'unknown'"
  696. class="font-mono text-xs break-all bg-gray-100 dark:bg-gray-800 p-3 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
  697. @click="copyToClipboard(currentMaintenanceCode)" v-tooltip.top="'点击复制'">
  698. 维护码:{{ currentMaintenanceCode }}
  699. </div>
  700. </div>
  701. <template #footer>
  702. <Button icon="pi pi-download" label="下载二维码" @click="downloadCurrentQrCode" />
  703. <Button label="关闭" severity="secondary" @click="showQrCodeDialog = false" />
  704. </template>
  705. </Dialog>
  706. <!-- 详情对话框 -->
  707. <Dialog v-model:visible="detailDialog" :modal="true" header="二维码详情" :style="{ width: '750px' }" position="center">
  708. <div v-if="detailLoading" class="py-10 text-center text-gray-500">详情加载中...</div>
  709. <div v-else-if="qrCodeDetail" class="space-y-4">
  710. <!-- 二维码基本信息 -->
  711. <div class="border rounded p-4">
  712. <h4 class="font-semibold mb-3">二维码信息</h4>
  713. <div class="grid grid-cols-2 gap-4">
  714. <div>
  715. <div class="text-sm text-gray-500">二维码编号</div>
  716. <div class="font-mono text-sm break-all">{{ qrCodeDetail.qrCode || '-' }}</div>
  717. </div>
  718. <div>
  719. <div class="text-sm text-gray-500">类型</div>
  720. <div>
  721. <Tag :severity="getTypeConfig(qrCodeDetail.qrType).severity">
  722. <i :class="`pi ${getTypeConfig(qrCodeDetail.qrType).icon} mr-1`"></i>
  723. {{ getTypeConfig(qrCodeDetail.qrType).label }}
  724. </Tag>
  725. </div>
  726. </div>
  727. <div>
  728. <div class="text-sm text-gray-500">激活状态</div>
  729. <div>
  730. <Tag :value="qrCodeDetail.isActivated ? '已激活' : '未激活'"
  731. :severity="qrCodeDetail.isActivated ? 'success' : 'secondary'" />
  732. </div>
  733. </div>
  734. <div>
  735. <div class="text-sm text-gray-500">扫描次数</div>
  736. <div class="font-semibold text-lg text-blue-600">{{ qrCodeDetail.scanCount || 0 }} 次</div>
  737. </div>
  738. <div>
  739. <div class="text-sm text-gray-500">创建时间</div>
  740. <div>{{ formatDate(qrCodeDetail.createdAt) }}</div>
  741. </div>
  742. <div>
  743. <div class="text-sm text-gray-500">更新时间</div>
  744. <div>{{ formatDate(qrCodeDetail.updatedAt) }}</div>
  745. </div>
  746. </div>
  747. </div>
  748. <!-- 关联信息 -->
  749. <div v-if="qrCodeDetail.info" class="border rounded p-4">
  750. <h4 class="font-semibold mb-3">关联信息</h4>
  751. <!-- 人员信息 -->
  752. <div v-if="qrCodeDetail.qrType === 'person'" class="grid grid-cols-2 gap-4">
  753. <div v-if="qrCodeDetail.info.photoUrl" class="col-span-2 text-center mb-2">
  754. <img :src="qrCodeDetail.info.photoUrl" alt="照片" class="w-32 h-32 object-cover rounded mx-auto" />
  755. </div>
  756. <div>
  757. <div class="text-sm text-gray-500">姓名</div>
  758. <div class="font-medium">{{ qrCodeDetail.info.name || '-' }}</div>
  759. </div>
  760. <div>
  761. <div class="text-sm text-gray-500">性别</div>
  762. <div class="font-medium">{{ qrCodeDetail.info.gender === 'male' ? '男' : qrCodeDetail.info.gender ===
  763. 'female'
  764. ? '女' : '其他' }}</div>
  765. </div>
  766. <div>
  767. <div class="text-sm text-gray-500">电话</div>
  768. <div>
  769. <a v-if="qrCodeDetail.info.phone" :href="`tel:${qrCodeDetail.info.phone}`"
  770. class="text-blue-600 hover:underline">
  771. {{ qrCodeDetail.info.phone }}
  772. </a>
  773. <span v-else>-</span>
  774. </div>
  775. </div>
  776. <div>
  777. <div class="text-sm text-gray-500">紧急联系人</div>
  778. <div class="font-medium">{{ qrCodeDetail.info.emergencyContactName || '-' }}</div>
  779. </div>
  780. <div>
  781. <div class="text-sm text-gray-500">紧急联系人电话</div>
  782. <div>
  783. <a v-if="qrCodeDetail.info.emergencyContactPhone"
  784. :href="`tel:${qrCodeDetail.info.emergencyContactPhone}`" class="text-blue-600 hover:underline">
  785. {{ qrCodeDetail.info.emergencyContactPhone }}
  786. </a>
  787. <span v-else>-</span>
  788. </div>
  789. </div>
  790. <div>
  791. <div class="text-sm text-gray-500">紧急联系人邮箱</div>
  792. <div>
  793. <a v-if="qrCodeDetail.info.emergencyContactEmail"
  794. :href="`mailto:${qrCodeDetail.info.emergencyContactEmail}`" class="text-blue-600 hover:underline">
  795. {{ qrCodeDetail.info.emergencyContactEmail }}
  796. </a>
  797. <span v-else>-</span>
  798. </div>
  799. </div>
  800. <div v-if="qrCodeDetail.info.specialNote" class="col-span-2">
  801. <div class="text-sm text-gray-500">特别说明</div>
  802. <div class="whitespace-pre-wrap">{{ qrCodeDetail.info.specialNote }}</div>
  803. </div>
  804. <div v-if="qrCodeDetail.info.remark" class="col-span-2">
  805. <div class="text-sm text-gray-500">备注</div>
  806. <div class="whitespace-pre-wrap">{{ qrCodeDetail.info.remark }}</div>
  807. </div>
  808. </div>
  809. <!-- 宠物/物品信息 -->
  810. <div v-else-if="qrCodeDetail.qrType === 'pet' || qrCodeDetail.qrType === 'goods'"
  811. class="grid grid-cols-2 gap-4">
  812. <div v-if="qrCodeDetail.info.photoUrl" class="col-span-2 text-center mb-2">
  813. <img :src="qrCodeDetail.info.photoUrl" alt="照片" class="w-32 h-32 object-cover rounded mx-auto" />
  814. </div>
  815. <div>
  816. <div class="text-sm text-gray-500">{{ qrCodeDetail.qrType === 'pet' ? '名称' : '物品名称' }}</div>
  817. <div class="font-medium">{{ qrCodeDetail.info.name || '-' }}</div>
  818. </div>
  819. <div>
  820. <div class="text-sm text-gray-500">联系人</div>
  821. <div class="font-medium">{{ qrCodeDetail.info.contactName || '-' }}</div>
  822. </div>
  823. <div>
  824. <div class="text-sm text-gray-500">联系电话</div>
  825. <div>
  826. <a v-if="qrCodeDetail.info.contactPhone" :href="`tel:${qrCodeDetail.info.contactPhone}`"
  827. class="text-blue-600 hover:underline">
  828. {{ qrCodeDetail.info.contactPhone }}
  829. </a>
  830. <span v-else>-</span>
  831. </div>
  832. </div>
  833. <div>
  834. <div class="text-sm text-gray-500">联系邮箱</div>
  835. <div>
  836. <a v-if="qrCodeDetail.info.contactEmail" :href="`mailto:${qrCodeDetail.info.contactEmail}`"
  837. class="text-blue-600 hover:underline">
  838. {{ qrCodeDetail.info.contactEmail }}
  839. </a>
  840. <span v-else>-</span>
  841. </div>
  842. </div>
  843. <div v-if="qrCodeDetail.info.remark">
  844. <div class="text-sm text-gray-500">备注</div>
  845. <div class="whitespace-pre-wrap">{{ qrCodeDetail.info.remark }}</div>
  846. </div>
  847. <div>
  848. <div class="text-sm text-gray-500">是否显示</div>
  849. <div>
  850. <Tag :value="qrCodeDetail.info.isVisible ? '显示' : '隐藏'"
  851. :severity="qrCodeDetail.info.isVisible ? 'success' : 'secondary'" />
  852. </div>
  853. </div>
  854. </div>
  855. <!-- 链接信息 -->
  856. <div v-else-if="qrCodeDetail.qrType === 'link'" class="grid grid-cols-2 gap-4">
  857. <div class="col-span-2">
  858. <div class="text-sm text-gray-500">跳转链接</div>
  859. <div>
  860. <a v-if="qrCodeDetail.info.jumpUrl" :href="qrCodeDetail.info.jumpUrl" target="_blank"
  861. class="text-blue-600 hover:underline break-all">
  862. {{ qrCodeDetail.info.jumpUrl }}
  863. </a>
  864. <span v-else class="text-gray-400">-</span>
  865. </div>
  866. </div>
  867. <div>
  868. <div class="text-sm text-gray-500">可见性</div>
  869. <div>
  870. <Tag :value="qrCodeDetail.info.isVisible ? '可见' : '隐藏'"
  871. :severity="qrCodeDetail.info.isVisible ? 'success' : 'secondary'" />
  872. </div>
  873. </div>
  874. <div v-if="qrCodeDetail.info.remark" class="col-span-2">
  875. <div class="text-sm text-gray-500">备注</div>
  876. <div class="whitespace-pre-wrap">{{ qrCodeDetail.info.remark }}</div>
  877. </div>
  878. </div>
  879. </div>
  880. <!-- 已激活,未绑定信息 -->
  881. <div v-else-if="qrCodeDetail.isActivated && !qrCodeDetail.info" class="border rounded p-4 text-center text-gray-500">
  882. <i class="pi pi-info-circle text-2xl mb-2"></i>
  883. <div>该二维码已激活,但未填写关联信息</div>
  884. </div>
  885. <!-- 未激活提示 -->
  886. <div v-else class="border rounded p-4 text-center text-gray-500">
  887. <i class="pi pi-info-circle text-2xl mb-2"></i>
  888. <div>该二维码尚未激活,暂无关联信息</div>
  889. </div>
  890. </div>
  891. <template #footer>
  892. <Button label="关闭" @click="detailDialog = false" />
  893. </template>
  894. </Dialog>
  895. </div>
  896. </template>