소스 검색

二维码页面

wuyi 1 개월 전
부모
커밋
8f995c0763
9개의 변경된 파일2183개의 추가작업 그리고 53개의 파일을 삭제
  1. 21 3
      src/router/index.js
  2. 141 1
      src/services/api.js
  3. 31 13
      src/views/MainView.vue
  4. 545 0
      src/views/PersonInfoManageView.vue
  5. 450 0
      src/views/PetInfoManageView.vue
  6. 484 0
      src/views/QrCodeManageView.vue
  7. 470 0
      src/views/ScanRecordView.vue
  8. 0 21
      src/views/SubMenuView.vue
  9. 41 15
      src/views/UserView.vue

+ 21 - 3
src/router/index.js

@@ -40,9 +40,27 @@ const router = createRouter({
           meta: { roles: ['admin'] }
         },
         {
-          path: 'sub-menu',
-          name: 'sub-menu',
-          component: () => import('@/views/SubMenuView.vue'),
+          path: 'qr-code-manage',
+          name: 'qr-code-manage',
+          component: () => import('@/views/QrCodeManageView.vue'),
+          meta: { roles: ['admin'] }
+        },
+        {
+          path: 'person-info-manage',
+          name: 'person-info-manage',
+          component: () => import('@/views/PersonInfoManageView.vue'),
+          meta: { roles: ['admin'] }
+        },
+        {
+          path: 'pet-info-manage',
+          name: 'pet-info-manage',
+          component: () => import('@/views/PetInfoManageView.vue'),
+          meta: { roles: ['admin'] }
+        },
+        {
+          path: 'scan-record',
+          name: 'scan-record',
+          component: () => import('@/views/ScanRecordView.vue'),
           meta: { roles: ['admin'] }
         }
       ]

+ 141 - 1
src/services/api.js

@@ -109,7 +109,6 @@ export const getSysConfigByName = async (name) => {
   return response.data
 }
 
-
 // 文件上传API
 export const uploadFile = async (file) => {
   const formData = new FormData()
@@ -134,3 +133,144 @@ export const downloadFile = async (key) => {
   )
   return response.data
 }
+
+// ==================== 二维码管理相关API ====================
+
+// 生成二维码
+export const generateQrCodes = async (qrType, quantity) => {
+  const response = await api.post('/qr/generate', {
+    qrType,
+    quantity
+  })
+  return response.data
+}
+
+// 查询二维码列表
+export const queryQrCodes = async (params) => {
+  const response = await api.get('/qr/list', { params })
+  return response.data
+}
+
+// 获取二维码信息
+export const getQrCodeInfo = async (qrCode) => {
+  const response = await api.get('/qr/info', {
+    params: { qrCode }
+  })
+  return response.data
+}
+
+// 获取二维码扫描记录
+export const getQrCodeScanRecords = async (qrCode, limit = 10) => {
+  const response = await api.get('/qr/scan', {
+    params: { qrCode, limit }
+  })
+  return response.data
+}
+
+// 按日期下载二维码
+export const downloadQrCodesByDate = async (date) => {
+  const response = await api.get('/qr/download', {
+    params: { date },
+    responseType: 'blob'
+  })
+  return response.data
+}
+
+// 验证维护码
+export const verifyMaintenanceCode = async (qrCode, maintenanceCode) => {
+  const response = await api.post('/qr/verify', {
+    qrCode,
+    maintenanceCode
+  })
+  return response.data
+}
+
+// ==================== 人员信息相关API ====================
+
+// 创建人员信息
+export const createPersonInfo = async (data) => {
+  const response = await api.post('/person/create', data)
+  return response.data
+}
+
+// 获取人员信息
+export const getPersonInfo = async (qrCode) => {
+  const response = await api.get('/person/get', {
+    params: { qrCode }
+  })
+  return response.data
+}
+
+// 更新人员信息(前台,需要维护码)
+export const updatePersonInfo = async (data) => {
+  const response = await api.put('/person/update', data)
+  return response.data
+}
+
+// 管理员更新人员信息
+export const adminUpdatePersonInfo = async (data) => {
+  const response = await api.post('/person/admin/update', data)
+  return response.data
+}
+
+// 查询人员信息列表(管理员)
+export const queryPersonInfo = async (params) => {
+  const response = await api.get('/person/list', { params })
+  return response.data
+}
+
+// ==================== 宠物信息相关API ====================
+
+// 创建宠物信息
+export const createPetInfo = async (data) => {
+  const response = await api.post('/pet/create', data)
+  return response.data
+}
+
+// 获取宠物信息
+export const getPetInfo = async (qrCode) => {
+  const response = await api.get('/pet/get', {
+    params: { qrCode }
+  })
+  return response.data
+}
+
+// 更新宠物信息(前台,需要维护码)
+export const updatePetInfo = async (data) => {
+  const response = await api.put('/pet/update', data)
+  return response.data
+}
+
+// 管理员更新宠物信息
+export const adminUpdatePetInfo = async (data) => {
+  const response = await api.post('/pet/admin/update', data)
+  return response.data
+}
+
+// 查询宠物信息列表(管理员)
+export const queryPetInfo = async (params) => {
+  const response = await api.get('/pet/list', { params })
+  return response.data
+}
+
+// ==================== 扫描记录相关API ====================
+
+// 创建扫描记录
+export const createScanRecord = async (data) => {
+  const response = await api.post('/scan/create', data)
+  return response.data
+}
+
+// 获取最近扫描记录
+export const getRecentScanRecords = async (qrCode, limit = 10) => {
+  const response = await api.get('/scan/recent', {
+    params: { qrCode, limit }
+  })
+  return response.data
+}
+
+// 查询扫描记录
+export const queryScanRecords = async (params) => {
+  const response = await api.get('/scan/list', { params })
+  return response.data
+}

+ 31 - 13
src/views/MainView.vue

@@ -37,6 +37,37 @@ const allNavItems = [
     name: 'dashboard',
     roles: ['user', 'admin', 'channel', 'operator', 'mss', 'show']
   },
+  {
+    label: '二维码系统',
+    icon: 'pi pi-fw pi-qrcode',
+    roles: ['admin'],
+    items: [
+      {
+        label: '二维码管理',
+        icon: 'pi pi-fw pi-qrcode',
+        name: 'qr-code-manage',
+        roles: ['admin']
+      },
+      {
+        label: '人员信息管理',
+        icon: 'pi pi-fw pi-users',
+        name: 'person-info-manage',
+        roles: ['admin']
+      },
+      {
+        label: '宠物信息管理',
+        icon: 'pi pi-fw pi-heart',
+        name: 'pet-info-manage',
+        roles: ['admin']
+      },
+      {
+        label: '扫描记录',
+        icon: 'pi pi-fw pi-chart-line',
+        name: 'scan-record',
+        roles: ['admin']
+      }
+    ]
+  },
   {
     label: '用户管理',
     icon: 'pi pi-fw pi-user',
@@ -49,19 +80,6 @@ const allNavItems = [
     name: 'sys-config',
     roles: ['admin']
   },
-  {
-    label: '母菜单示例',
-    icon: 'pi pi-fw pi-list',
-    roles: ['admin'],
-    items: [
-      {
-        label: '子菜单示例',
-        icon: 'pi pi-fw pi-circle',
-        name: 'sub-menu',
-        roles: ['admin']
-      },
-    ]
-  }
 ]
 
 // 根据用户角色过滤菜单项

+ 545 - 0
src/views/PersonInfoManageView.vue

@@ -0,0 +1,545 @@
+<script setup>
+import {
+  queryPersonInfo,
+  adminUpdatePersonInfo,
+  getQrCodeInfo,
+  uploadFile
+} from '@/services/api'
+import { Form } from '@primevue/forms'
+import { zodResolver } from '@primevue/forms/resolvers/zod'
+import { useDateFormat } from '@vueuse/core'
+import Button from 'primevue/button'
+import Column from 'primevue/column'
+import DataTable from 'primevue/datatable'
+import Dialog from 'primevue/dialog'
+import Select from 'primevue/select'
+import FloatLabel from 'primevue/floatlabel'
+import IconField from 'primevue/iconfield'
+import InputIcon from 'primevue/inputicon'
+import InputText from 'primevue/inputtext'
+import Textarea from 'primevue/textarea'
+import FileUpload from 'primevue/fileupload'
+import Image from 'primevue/image'
+import Message from 'primevue/message'
+import Tag from 'primevue/tag'
+import { useToast } from 'primevue/usetoast'
+import { computed, onMounted, ref } from 'vue'
+import { z } from 'zod'
+
+const toast = useToast()
+
+// 表格数据
+const tableData = ref({
+  content: [],
+  metadata: {
+    page: 0,
+    size: 20,
+    total: 0
+  }
+})
+
+// 筛选条件
+const filters = ref({
+  name: '',
+  phone: '',
+  qrCode: '',
+  gender: null
+})
+
+// 性别选项
+const genderOptions = [
+  { label: '全部', value: null },
+  { label: '男', value: 'male' },
+  { label: '女', value: 'female' },
+  { label: '其他', value: 'other' }
+]
+
+// 获取性别名称
+const getGenderName = (gender) => {
+  const map = {
+    male: '男',
+    female: '女',
+    other: '其他'
+  }
+  return map[gender] || '-'
+}
+
+// 格式化日期
+const formatDate = (date) => {
+  if (!date) return '-'
+  return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
+}
+
+// 获取数据
+const fetchData = async () => {
+  try {
+    const params = {
+      page: tableData.value.metadata.page,
+      pageSize: tableData.value.metadata.size
+    }
+
+    if (filters.value.name) params.name = filters.value.name
+    if (filters.value.phone) params.phone = filters.value.phone
+    if (filters.value.qrCode) params.qrCode = filters.value.qrCode
+    if (filters.value.gender) params.gender = filters.value.gender
+
+    const response = await queryPersonInfo(params)
+    tableData.value.content = response.data
+    tableData.value.metadata.total = response.total
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取数据失败',
+      life: 3000
+    })
+  }
+}
+
+// 分页
+const handlePageChange = (event) => {
+  tableData.value.metadata.page = event.page
+  tableData.value.metadata.size = event.rows
+  fetchData()
+}
+
+// 重置筛选并刷新
+const resetAndRefresh = () => {
+  filters.value = {
+    name: '',
+    phone: '',
+    qrCode: '',
+    gender: null
+  }
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
+// 编辑对话框
+const editDialog = ref(false)
+const editForm = ref({
+  qrCodeId: null,
+  photoUrl: '',
+  name: '',
+  gender: 'male',
+  phone: '',
+  specialNote: '',
+  emergencyContactName: '',
+  emergencyContactPhone: '',
+  emergencyContactEmail: ''
+})
+const editFormLoading = ref(false)
+const uploadedPhotoUrl = ref('')
+
+// 表单验证
+const editFormResolver = zodResolver(
+  z.object({
+    name: z.string().min(1, { message: '姓名不能为空' }),
+    gender: z.enum(['male', 'female', 'other'], { message: '请选择性别' }),
+    phone: z.string().min(1, { message: '电话不能为空' }),
+    emergencyContactName: z.string().min(1, { message: '紧急联系人姓名不能为空' }),
+    emergencyContactPhone: z.string().min(1, { message: '紧急联系人电话不能为空' }),
+    emergencyContactEmail: z.string().email({ message: '邮箱格式不正确' }).optional().or(z.literal(''))
+  })
+)
+
+// 打开编辑对话框
+const openEditDialog = async (person) => {
+  editForm.value = {
+    qrCodeId: person.qrCodeId,
+    photoUrl: person.photoUrl || '',
+    name: person.name,
+    gender: person.gender,
+    phone: person.phone,
+    specialNote: person.specialNote || '',
+    emergencyContactName: person.emergencyContactName,
+    emergencyContactPhone: person.emergencyContactPhone,
+    emergencyContactEmail: person.emergencyContactEmail || ''
+  }
+  uploadedPhotoUrl.value = person.photoUrl || ''
+  editDialog.value = true
+}
+
+// 照片上传
+const handlePhotoUpload = async (event) => {
+  const file = event.files[0]
+  if (!file) return
+
+  try {
+    const response = await uploadFile(file)
+    uploadedPhotoUrl.value = response.url
+    editForm.value.photoUrl = response.url
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '照片上传成功',
+      life: 3000
+    })
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '照片上传失败',
+      life: 3000
+    })
+  }
+}
+
+// 保存编辑
+const saveEdit = async ({ valid, values }) => {
+  if (!valid) return
+
+  editFormLoading.value = true
+  try {
+    const submitData = {
+      qrCodeId: editForm.value.qrCodeId,
+      photoUrl: uploadedPhotoUrl.value,
+      name: values.name,
+      gender: values.gender,
+      phone: values.phone,
+      specialNote: values.specialNote || '',
+      emergencyContactName: values.emergencyContactName,
+      emergencyContactPhone: values.emergencyContactPhone,
+      emergencyContactEmail: values.emergencyContactEmail || ''
+    }
+
+    await adminUpdatePersonInfo(submitData)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '人员信息更新成功',
+      life: 3000
+    })
+    editDialog.value = false
+    fetchData()
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '更新失败',
+      life: 3000
+    })
+  } finally {
+    editFormLoading.value = false
+  }
+}
+
+// 查看详情对话框
+const detailDialog = ref(false)
+const selectedPerson = ref(null)
+const qrCodeDetail = ref(null)
+
+const viewDetail = async (person) => {
+  try {
+    selectedPerson.value = person
+    const response = await getQrCodeInfo(person.qrCode)
+    qrCodeDetail.value = response
+    detailDialog.value = true
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取详情失败',
+      life: 3000
+    })
+  }
+}
+
+// 复制到剪贴板
+const copyToClipboard = (text) => {
+  navigator.clipboard.writeText(text).then(() => {
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '已复制到剪贴板',
+      life: 2000
+    })
+  })
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<template>
+  <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
+    <DataTable :value="tableData.content" :paginator="true"
+      paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
+      currentPageReportTemplate="{totalRecords} 条记录" :rows="tableData.metadata.size"
+      :rowsPerPageOptions="[10, 20, 50, 100]" :totalRecords="tableData.metadata.total" @page="handlePageChange" lazy
+      scrollable>
+      <template #header>
+        <div class="flex flex-wrap items-center gap-2">
+          <!-- 筛选条件 - 左侧 -->
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-user" />
+            </InputIcon>
+            <InputText v-model="filters.name" placeholder="姓名" size="small" class="w-32" />
+          </IconField>
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-phone" />
+            </InputIcon>
+            <InputText v-model="filters.phone" placeholder="电话" size="small" class="w-32" />
+          </IconField>
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-qrcode" />
+            </InputIcon>
+            <InputText v-model="filters.qrCode" placeholder="二维码编号" size="small" class="w-32" />
+          </IconField>
+          <Select v-model="filters.gender" :options="genderOptions" optionLabel="label" optionValue="value"
+            placeholder="性别" class="w-30" size="small" />
+          <Button icon="pi pi-search" @click="fetchData" label="查询" size="small" />
+          <Button icon="pi pi-refresh" @click="resetAndRefresh" label="刷新" size="small" />
+        </div>
+      </template>
+
+      <Column field="qrCode" header="二维码编号" style="min-width: 180px">
+        <template #body="slotProps">
+          <div class="flex items-center gap-2">
+            <code class="text-xs">{{ slotProps.data.qrCode }}</code>
+            <Button icon="pi pi-copy" size="small" text rounded @click="copyToClipboard(slotProps.data.qrCode)" />
+          </div>
+        </template>
+      </Column>
+      <Column field="photoUrl" header="照片" style="min-width: 80px">
+        <template #body="slotProps">
+          <Image v-if="slotProps.data.photoUrl" :src="slotProps.data.photoUrl" alt="照片" width="50" preview />
+          <span v-else class="text-gray-400">-</span>
+        </template>
+      </Column>
+      <Column field="name" header="姓名" style="min-width: 100px"></Column>
+      <Column field="gender" header="性别" style="min-width: 80px">
+        <template #body="slotProps">
+          {{ getGenderName(slotProps.data.gender) }}
+        </template>
+      </Column>
+      <Column field="phone" header="电话" style="min-width: 130px">
+        <template #body="slotProps">
+          <a :href="`tel:${slotProps.data.phone}`" class="text-blue-600 hover:underline">
+            {{ slotProps.data.phone }}
+          </a>
+        </template>
+      </Column>
+      <Column field="emergencyContactName" header="紧急联系人" style="min-width: 120px"></Column>
+      <Column field="emergencyContactPhone" header="紧急联系人电话" style="min-width: 150px">
+        <template #body="slotProps">
+          <a :href="`tel:${slotProps.data.emergencyContactPhone}`" class="text-blue-600 hover:underline">
+            {{ slotProps.data.emergencyContactPhone }}
+          </a>
+        </template>
+      </Column>
+      <Column field="createdAt" header="创建时间" style="min-width: 180px">
+        <template #body="slotProps">
+          {{ formatDate(slotProps.data.createdAt) }}
+        </template>
+      </Column>
+      <Column header="操作" style="min-width: 150px" frozen alignFrozen="right">
+        <template #body="slotProps">
+          <div class="flex gap-1">
+            <Button icon="pi pi-eye" severity="info" size="small" text rounded v-tooltip.top="'查看详情'"
+              @click="viewDetail(slotProps.data)" />
+            <Button icon="pi pi-pencil" severity="warn" size="small" text rounded v-tooltip.top="'编辑'"
+              @click="openEditDialog(slotProps.data)" />
+          </div>
+        </template>
+      </Column>
+    </DataTable>
+
+    <!-- 编辑对话框 -->
+    <Dialog v-model:visible="editDialog" :modal="true" header="编辑人员信息" :style="{ width: '650px' }" position="center">
+      <Form v-slot="$form" :resolver="editFormResolver" :initialValues="editForm" @submit="saveEdit" class="p-fluid">
+        <!-- 照片上传 -->
+        <div class="field mb-4">
+          <label class="block mb-2">照片</label>
+          <div class="flex gap-4 items-start">
+            <Image v-if="uploadedPhotoUrl" :src="uploadedPhotoUrl" alt="照片预览" width="120" preview />
+            <FileUpload mode="basic" accept="image/*" :maxFileSize="5000000" chooseLabel="上传照片"
+              @select="handlePhotoUpload" :auto="true" />
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div class="field">
+            <FloatLabel variant="on">
+              <InputText id="name" name="name" v-model="editForm.name" fluid />
+              <label for="name">姓名 *</label>
+            </FloatLabel>
+            <Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">
+              {{ $form.name.error?.message }}
+            </Message>
+          </div>
+
+          <div class="field">
+            <FloatLabel variant="on">
+              <Select id="gender" name="gender" v-model="editForm.gender" :options="[
+                { label: '男', value: 'male' },
+                { label: '女', value: 'female' },
+                { label: '其他', value: 'other' }
+              ]" optionLabel="label" optionValue="value" fluid />
+              <label for="gender">性别 *</label>
+            </FloatLabel>
+            <Message v-if="$form.gender?.invalid" severity="error" size="small" variant="simple">
+              {{ $form.gender.error?.message }}
+            </Message>
+          </div>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <InputText id="phone" name="phone" v-model="editForm.phone" fluid />
+            <label for="phone">电话 *</label>
+          </FloatLabel>
+          <Message v-if="$form.phone?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.phone.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <Textarea id="specialNote" name="specialNote" v-model="editForm.specialNote" rows="3" fluid />
+            <label for="specialNote">特别说明(如血型、过敏史等)</label>
+          </FloatLabel>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <InputText id="emergencyContactName" name="emergencyContactName" v-model="editForm.emergencyContactName"
+              fluid />
+            <label for="emergencyContactName">紧急联系人姓名 *</label>
+          </FloatLabel>
+          <Message v-if="$form.emergencyContactName?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.emergencyContactName.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <InputText id="emergencyContactPhone" name="emergencyContactPhone" v-model="editForm.emergencyContactPhone"
+              fluid />
+            <label for="emergencyContactPhone">紧急联系人电话 *</label>
+          </FloatLabel>
+          <Message v-if="$form.emergencyContactPhone?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.emergencyContactPhone.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <InputText id="emergencyContactEmail" name="emergencyContactEmail" v-model="editForm.emergencyContactEmail"
+              fluid />
+            <label for="emergencyContactEmail">紧急联系人邮箱</label>
+          </FloatLabel>
+          <Message v-if="$form.emergencyContactEmail?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.emergencyContactEmail.error?.message }}
+          </Message>
+        </div>
+
+        <div class="flex justify-end gap-2 mt-4">
+          <Button label="取消" severity="secondary" type="button" @click="editDialog = false"
+            :disabled="editFormLoading" />
+          <Button label="保存" type="submit" :loading="editFormLoading" />
+        </div>
+      </Form>
+    </Dialog>
+
+    <!-- 详情对话框 -->
+    <Dialog v-model:visible="detailDialog" :modal="true" header="人员信息详情" :style="{ width: '600px' }" position="center">
+      <div v-if="selectedPerson" class="space-y-4">
+        <!-- 照片 -->
+        <div v-if="selectedPerson.photoUrl" class="text-center">
+          <Image :src="selectedPerson.photoUrl" alt="照片" width="150" preview />
+        </div>
+
+        <!-- 基本信息 -->
+        <div class="border rounded p-4">
+          <h4 class="font-semibold mb-3">基本信息</h4>
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <div class="text-sm text-gray-500">二维码</div>
+              <div class="font-mono">{{ selectedPerson.qrCode }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">姓名</div>
+              <div>{{ selectedPerson.name }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">性别</div>
+              <div>{{ getGenderName(selectedPerson.gender) }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">电话</div>
+              <div>
+                <a :href="`tel:${selectedPerson.phone}`" class="text-blue-600 hover:underline">
+                  {{ selectedPerson.phone }}
+                </a>
+              </div>
+            </div>
+          </div>
+          <div v-if="selectedPerson.specialNote" class="mt-3">
+            <div class="text-sm text-gray-500">特别说明</div>
+            <div class="whitespace-pre-wrap">{{ selectedPerson.specialNote }}</div>
+          </div>
+        </div>
+
+        <!-- 紧急联系人信息 -->
+        <div class="border rounded p-4">
+          <h4 class="font-semibold mb-3">紧急联系人</h4>
+          <div class="space-y-2">
+            <div>
+              <div class="text-sm text-gray-500">姓名</div>
+              <div>{{ selectedPerson.emergencyContactName }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">电话</div>
+              <div>
+                <a :href="`tel:${selectedPerson.emergencyContactPhone}`" class="text-blue-600 hover:underline">
+                  {{ selectedPerson.emergencyContactPhone }}
+                </a>
+              </div>
+            </div>
+            <div v-if="selectedPerson.emergencyContactEmail">
+              <div class="text-sm text-gray-500">邮箱</div>
+              <div>
+                <a :href="`mailto:${selectedPerson.emergencyContactEmail}`" class="text-blue-600 hover:underline">
+                  {{ selectedPerson.emergencyContactEmail }}
+                </a>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 二维码统计信息 -->
+        <div v-if="qrCodeDetail" class="border rounded p-4">
+          <h4 class="font-semibold mb-3">统计信息</h4>
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <div class="text-sm text-gray-500">激活状态</div>
+              <Tag :value="qrCodeDetail.isActivated ? '已激活' : '未激活'"
+                :severity="qrCodeDetail.isActivated ? 'success' : 'secondary'" />
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">扫描次数</div>
+              <div class="font-semibold text-lg">{{ qrCodeDetail.scanCount || 0 }} 次</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">创建时间</div>
+              <div>{{ formatDate(selectedPerson.createdAt) }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">更新时间</div>
+              <div>{{ formatDate(selectedPerson.updatedAt) }}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <Button label="关闭" @click="detailDialog = false" />
+      </template>
+    </Dialog>
+  </div>
+</template>

+ 450 - 0
src/views/PetInfoManageView.vue

@@ -0,0 +1,450 @@
+<script setup>
+import { queryPetInfo, adminUpdatePetInfo, getQrCodeInfo, uploadFile } from '@/services/api'
+import { Form } from '@primevue/forms'
+import { zodResolver } from '@primevue/forms/resolvers/zod'
+import { useDateFormat } from '@vueuse/core'
+import Button from 'primevue/button'
+import Column from 'primevue/column'
+import DataTable from 'primevue/datatable'
+import Dialog from 'primevue/dialog'
+import FloatLabel from 'primevue/floatlabel'
+import IconField from 'primevue/iconfield'
+import InputIcon from 'primevue/inputicon'
+import InputText from 'primevue/inputtext'
+import FileUpload from 'primevue/fileupload'
+import Image from 'primevue/image'
+import Message from 'primevue/message'
+import Tag from 'primevue/tag'
+import { useToast } from 'primevue/usetoast'
+import { computed, onMounted, ref } from 'vue'
+import { z } from 'zod'
+
+const toast = useToast()
+
+// 表格数据
+const tableData = ref({
+  content: [],
+  metadata: {
+    page: 0,
+    size: 20,
+    total: 0
+  }
+})
+
+// 筛选条件
+const filters = ref({
+  name: '',
+  contactName: '',
+  qrCode: ''
+})
+
+// 格式化日期
+const formatDate = (date) => {
+  if (!date) return '-'
+  return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
+}
+
+// 获取数据
+const fetchData = async () => {
+  try {
+    const params = {
+      page: tableData.value.metadata.page,
+      pageSize: tableData.value.metadata.size
+    }
+
+    if (filters.value.name) params.name = filters.value.name
+    if (filters.value.contactName) params.contactName = filters.value.contactName
+    if (filters.value.qrCode) params.qrCode = filters.value.qrCode
+
+    const response = await queryPetInfo(params)
+    tableData.value.content = response.data
+    tableData.value.metadata.total = response.total
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取数据失败',
+      life: 3000
+    })
+  }
+}
+
+// 分页
+const handlePageChange = (event) => {
+  tableData.value.metadata.page = event.page
+  tableData.value.metadata.size = event.rows
+  fetchData()
+}
+
+// 重置筛选并刷新
+const resetAndRefresh = () => {
+  filters.value = {
+    name: '',
+    contactName: '',
+    qrCode: ''
+  }
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
+// 编辑对话框
+const editDialog = ref(false)
+const editForm = ref({
+  qrCodeId: null,
+  photoUrl: '',
+  name: '',
+  contactName: '',
+  contactPhone: '',
+  contactEmail: ''
+})
+const editFormLoading = ref(false)
+const uploadedPhotoUrl = ref('')
+
+// 表单验证
+const editFormResolver = zodResolver(
+  z.object({
+    name: z.string().min(1, { message: '名称不能为空' }),
+    contactName: z.string().min(1, { message: '联系人姓名不能为空' }),
+    contactPhone: z.string().min(1, { message: '联系人电话不能为空' }),
+    contactEmail: z.string().email({ message: '邮箱格式不正确' }).optional().or(z.literal(''))
+  })
+)
+
+// 打开编辑对话框
+const openEditDialog = async (pet) => {
+  editForm.value = {
+    qrCodeId: pet.qrCodeId,
+    photoUrl: pet.photoUrl || '',
+    name: pet.name,
+    contactName: pet.contactName,
+    contactPhone: pet.contactPhone,
+    contactEmail: pet.contactEmail || ''
+  }
+  uploadedPhotoUrl.value = pet.photoUrl || ''
+  editDialog.value = true
+}
+
+// 照片上传
+const handlePhotoUpload = async (event) => {
+  const file = event.files[0]
+  if (!file) return
+
+  try {
+    const response = await uploadFile(file)
+    uploadedPhotoUrl.value = response.url
+    editForm.value.photoUrl = response.url
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '照片上传成功',
+      life: 3000
+    })
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '照片上传失败',
+      life: 3000
+    })
+  }
+}
+
+// 保存编辑
+const saveEdit = async ({ valid, values }) => {
+  if (!valid) return
+
+  editFormLoading.value = true
+  try {
+    const submitData = {
+      qrCodeId: editForm.value.qrCodeId,
+      photoUrl: uploadedPhotoUrl.value,
+      name: values.name,
+      contactName: values.contactName,
+      contactPhone: values.contactPhone,
+      contactEmail: values.contactEmail || ''
+    }
+
+    await adminUpdatePetInfo(submitData)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '宠物/物品信息更新成功',
+      life: 3000
+    })
+    editDialog.value = false
+    fetchData()
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '更新失败',
+      life: 3000
+    })
+  } finally {
+    editFormLoading.value = false
+  }
+}
+
+// 查看详情对话框
+const detailDialog = ref(false)
+const selectedPet = ref(null)
+const qrCodeDetail = ref(null)
+
+const viewDetail = async (pet) => {
+  try {
+    selectedPet.value = pet
+    const response = await getQrCodeInfo(pet.qrCode)
+    qrCodeDetail.value = response
+    detailDialog.value = true
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取详情失败',
+      life: 3000
+    })
+  }
+}
+
+// 复制到剪贴板
+const copyToClipboard = (text) => {
+  navigator.clipboard.writeText(text).then(() => {
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '已复制到剪贴板',
+      life: 2000
+    })
+  })
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<template>
+  <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
+    <DataTable :value="tableData.content" :paginator="true"
+      paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
+      currentPageReportTemplate="{totalRecords} 条记录" :rows="tableData.metadata.size"
+      :rowsPerPageOptions="[10, 20, 50, 100]" :totalRecords="tableData.metadata.total" @page="handlePageChange" lazy
+      scrollable>
+      <template #header>
+        <div class="flex flex-wrap items-center gap-2">
+          <!-- 筛选条件 - 左侧 -->
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-tag" />
+            </InputIcon>
+            <InputText v-model="filters.name" placeholder="名称" size="small" class="w-32" />
+          </IconField>
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-user" />
+            </InputIcon>
+            <InputText v-model="filters.contactName" placeholder="联系人" size="small" class="w-32" />
+          </IconField>
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-qrcode" />
+            </InputIcon>
+            <InputText v-model="filters.qrCode" placeholder="二维码编号" size="small" class="w-32" />
+          </IconField>
+          <Button icon="pi pi-search" @click="fetchData" label="查询" size="small" />
+          <Button icon="pi pi-refresh" @click="resetAndRefresh" label="刷新" size="small" />
+        </div>
+      </template>
+
+      <Column field="qrCode" header="二维码编号" style="min-width: 180px">
+        <template #body="slotProps">
+          <div class="flex items-center gap-2">
+            <code class="text-xs">{{ slotProps.data.qrCode }}</code>
+            <Button icon="pi pi-copy" size="small" text rounded @click="copyToClipboard(slotProps.data.qrCode)" />
+          </div>
+        </template>
+      </Column>
+      <Column field="photoUrl" header="照片" style="min-width: 80px">
+        <template #body="slotProps">
+          <Image v-if="slotProps.data.photoUrl" :src="slotProps.data.photoUrl" alt="照片" width="50" preview />
+          <span v-else class="text-gray-400">-</span>
+        </template>
+      </Column>
+      <Column field="name" header="名称" style="min-width: 120px"></Column>
+      <Column field="contactName" header="联系人" style="min-width: 100px"></Column>
+      <Column field="contactPhone" header="联系电话" style="min-width: 130px">
+        <template #body="slotProps">
+          <a :href="`tel:${slotProps.data.contactPhone}`" class="text-blue-600 hover:underline">
+            {{ slotProps.data.contactPhone }}
+          </a>
+        </template>
+      </Column>
+      <Column field="contactEmail" header="邮箱" style="min-width: 180px">
+        <template #body="slotProps">
+          <a v-if="slotProps.data.contactEmail" :href="`mailto:${slotProps.data.contactEmail}`"
+            class="text-blue-600 hover:underline">
+            {{ slotProps.data.contactEmail }}
+          </a>
+          <span v-else class="text-gray-400">-</span>
+        </template>
+      </Column>
+      <Column field="createdAt" header="创建时间" style="min-width: 180px">
+        <template #body="slotProps">
+          {{ formatDate(slotProps.data.createdAt) }}
+        </template>
+      </Column>
+      <Column header="操作" style="min-width: 150px" frozen alignFrozen="right">
+        <template #body="slotProps">
+          <div class="flex gap-1">
+            <Button icon="pi pi-eye" severity="info" size="small" text rounded v-tooltip.top="'查看详情'"
+              @click="viewDetail(slotProps.data)" />
+            <Button icon="pi pi-pencil" severity="warn" size="small" text rounded v-tooltip.top="'编辑'"
+              @click="openEditDialog(slotProps.data)" />
+          </div>
+        </template>
+      </Column>
+    </DataTable>
+
+    <!-- 编辑对话框 -->
+    <Dialog v-model:visible="editDialog" :modal="true" header="编辑宠物/物品信息" :style="{ width: '550px' }" position="center">
+      <Form v-slot="$form" :resolver="editFormResolver" :initialValues="editForm" @submit="saveEdit" class="p-fluid">
+        <!-- 照片上传 -->
+        <div class="field mb-4">
+          <label class="block mb-2">照片</label>
+          <div class="flex gap-4 items-start">
+            <Image v-if="uploadedPhotoUrl" :src="uploadedPhotoUrl" alt="照片预览" width="120" preview />
+            <FileUpload mode="basic" accept="image/*" :maxFileSize="5000000" chooseLabel="上传照片"
+              @select="handlePhotoUpload" :auto="true" />
+          </div>
+        </div>
+
+        <div class="field">
+          <FloatLabel variant="on">
+            <InputText id="name" name="name" v-model="editForm.name" fluid />
+            <label for="name">名称 *</label>
+          </FloatLabel>
+          <Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.name.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <InputText id="contactName" name="contactName" v-model="editForm.contactName" fluid />
+            <label for="contactName">联系人姓名 *</label>
+          </FloatLabel>
+          <Message v-if="$form.contactName?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.contactName.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <InputText id="contactPhone" name="contactPhone" v-model="editForm.contactPhone" fluid />
+            <label for="contactPhone">联系电话 *</label>
+          </FloatLabel>
+          <Message v-if="$form.contactPhone?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.contactPhone.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <InputText id="contactEmail" name="contactEmail" v-model="editForm.contactEmail" fluid />
+            <label for="contactEmail">联系邮箱</label>
+          </FloatLabel>
+          <Message v-if="$form.contactEmail?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.contactEmail.error?.message }}
+          </Message>
+        </div>
+
+        <div class="flex justify-end gap-2 mt-4">
+          <Button label="取消" severity="secondary" type="button" @click="editDialog = false"
+            :disabled="editFormLoading" />
+          <Button label="保存" type="submit" :loading="editFormLoading" />
+        </div>
+      </Form>
+    </Dialog>
+
+    <!-- 详情对话框 -->
+    <Dialog v-model:visible="detailDialog" :modal="true" header="宠物/物品信息详情" :style="{ width: '550px' }"
+      position="center">
+      <div v-if="selectedPet" class="space-y-4">
+        <!-- 照片 -->
+        <div v-if="selectedPet.photoUrl" class="text-center">
+          <Image :src="selectedPet.photoUrl" alt="照片" width="150" preview />
+        </div>
+
+        <!-- 基本信息 -->
+        <div class="border rounded p-4">
+          <h4 class="font-semibold mb-3">基本信息</h4>
+          <div class="space-y-2">
+            <div>
+              <div class="text-sm text-gray-500">二维码</div>
+              <div class="font-mono">{{ selectedPet.qrCode }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">名称</div>
+              <div>{{ selectedPet.name }}</div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 联系人信息 -->
+        <div class="border rounded p-4">
+          <h4 class="font-semibold mb-3">联系人信息</h4>
+          <div class="space-y-2">
+            <div>
+              <div class="text-sm text-gray-500">联系人</div>
+              <div>{{ selectedPet.contactName }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">电话</div>
+              <div>
+                <a :href="`tel:${selectedPet.contactPhone}`" class="text-blue-600 hover:underline">
+                  {{ selectedPet.contactPhone }}
+                </a>
+              </div>
+            </div>
+            <div v-if="selectedPet.contactEmail">
+              <div class="text-sm text-gray-500">邮箱</div>
+              <div>
+                <a :href="`mailto:${selectedPet.contactEmail}`" class="text-blue-600 hover:underline">
+                  {{ selectedPet.contactEmail }}
+                </a>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 二维码统计信息 -->
+        <div v-if="qrCodeDetail" class="border rounded p-4">
+          <h4 class="font-semibold mb-3">统计信息</h4>
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <div class="text-sm text-gray-500">激活状态</div>
+              <Tag :value="qrCodeDetail.isActivated ? '已激活' : '未激活'"
+                :severity="qrCodeDetail.isActivated ? 'success' : 'secondary'" />
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">扫描次数</div>
+              <div class="font-semibold text-lg">{{ qrCodeDetail.scanCount || 0 }} 次</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">创建时间</div>
+              <div>{{ formatDate(selectedPet.createdAt) }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">更新时间</div>
+              <div>{{ formatDate(selectedPet.updatedAt) }}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <Button label="关闭" @click="detailDialog = false" />
+      </template>
+    </Dialog>
+  </div>
+</template>

+ 484 - 0
src/views/QrCodeManageView.vue

@@ -0,0 +1,484 @@
+<script setup>
+import {
+  generateQrCodes,
+  queryQrCodes,
+  downloadQrCodesByDate,
+  getQrCodeScanRecords
+} from '@/services/api'
+import { useDateFormat } from '@vueuse/core'
+import Button from 'primevue/button'
+import Column from 'primevue/column'
+import DataTable from 'primevue/datatable'
+import Dialog from 'primevue/dialog'
+import Select from 'primevue/select'
+import FloatLabel from 'primevue/floatlabel'
+import IconField from 'primevue/iconfield'
+import InputIcon from 'primevue/inputicon'
+import InputText from 'primevue/inputtext'
+import InputNumber from 'primevue/inputnumber'
+import DatePicker from 'primevue/datepicker'
+import Tag from 'primevue/tag'
+import { useToast } from 'primevue/usetoast'
+import { computed, onMounted, ref } from 'vue'
+
+const toast = useToast()
+
+// 表格数据
+const tableData = ref({
+  content: [],
+  metadata: {
+    page: 0,
+    size: 20,
+    total: 0
+  }
+})
+
+// 筛选条件
+const filters = ref({
+  qrType: null,
+  isActivated: null,
+  startDate: null,
+  endDate: null,
+  search: ''
+})
+
+// 二维码类型选项
+const qrTypeOptions = [
+  { label: '全部', value: null },
+  { label: '人员', value: 'person' },
+  { label: '宠物|物品', value: 'pet' }
+]
+
+// 激活状态选项
+const activatedOptions = [
+  { label: '全部', value: null },
+  { label: '已激活', value: true },
+  { label: '未激活', value: false }
+]
+
+// 生成二维码对话框
+const generateDialog = ref(false)
+const generateForm = ref({
+  qrType: 'person',
+  quantity: 10
+})
+const generateLoading = ref(false)
+const generatedCodes = ref([])
+
+// 扫描记录对话框
+const scanRecordDialog = ref(false)
+const scanRecords = ref([])
+const selectedQrCode = ref(null)
+
+// 批量下载对话框
+const batchDownloadDialog = ref(false)
+const downloadDate = ref(null)
+
+// 获取二维码类型名称
+const getQrTypeName = (type) => {
+  return type === 'person' ? '人员' : '宠物|物品'
+}
+
+// 获取激活状态标签
+const getActivatedTag = (isActivated) => {
+  return isActivated
+    ? { severity: 'success', label: '已激活' }
+    : { severity: 'secondary', label: '未激活' }
+}
+
+// 格式化日期
+const formatDate = (date) => {
+  if (!date) return '-'
+  return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
+}
+
+const formatDateShort = (date) => {
+  if (!date) return '-'
+  return useDateFormat(new Date(date), 'YYYY-MM-DD').value
+}
+
+// 获取数据
+const fetchData = async () => {
+  try {
+    const params = {
+      page: tableData.value.metadata.page,
+      pageSize: tableData.value.metadata.size
+    }
+
+    if (filters.value.qrType) params.qrType = filters.value.qrType
+    if (filters.value.isActivated !== null) params.isActivated = filters.value.isActivated
+    if (filters.value.startDate)
+      params.startDate = useDateFormat(filters.value.startDate, 'YYYY-MM-DD').value
+    if (filters.value.endDate) params.endDate = useDateFormat(filters.value.endDate, 'YYYY-MM-DD').value
+
+    if (filters.value.search?.trim()) params.search = filters.value.search.trim()
+
+    const response = await queryQrCodes(params)
+
+    tableData.value.content = response.content || []
+    tableData.value.metadata = {
+      page: response.metadata?.page ?? tableData.value.metadata.page,
+      size: response.metadata?.size ?? tableData.value.metadata.size,
+      total: response.metadata?.total ?? 0
+    }
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取数据失败',
+      life: 3000
+    })
+  }
+}
+
+// 分页
+const handlePageChange = (event) => {
+  tableData.value.metadata.page = event.page
+  tableData.value.metadata.size = event.rows
+  fetchData()
+}
+
+// 重置筛选并刷新
+const resetAndRefresh = () => {
+  filters.value = {
+    qrType: null,
+    isActivated: null,
+    startDate: null,
+    endDate: null,
+    search: ''
+  }
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
+// 打开生成对话框
+const openGenerateDialog = () => {
+  generateForm.value = {
+    qrType: 'person',
+    quantity: 10
+  }
+  generatedCodes.value = []
+  generateDialog.value = true
+}
+
+// 打开批量下载对话框
+const openBatchDownloadDialog = () => {
+  downloadDate.value = null
+  batchDownloadDialog.value = true
+}
+
+// 确认批量下载
+const handleBatchDownload = async () => {
+  if (!downloadDate.value) {
+    toast.add({
+      severity: 'warn',
+      summary: '警告',
+      detail: '请选择下载日期',
+      life: 3000
+    })
+    return
+  }
+  await downloadByDate(downloadDate.value)
+  batchDownloadDialog.value = false
+}
+
+// 生成二维码
+const handleGenerate = async () => {
+  if (generateForm.value.quantity < 1 || generateForm.value.quantity > 1000) {
+    toast.add({
+      severity: 'warn',
+      summary: '警告',
+      detail: '生成数量必须在1-1000之间',
+      life: 3000
+    })
+    return
+  }
+
+  generateLoading.value = true
+  try {
+    const response = await generateQrCodes(generateForm.value.qrType, generateForm.value.quantity)
+    generatedCodes.value = response.data
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: `成功生成 ${response.data.length} 个二维码`,
+      life: 3000
+    })
+    fetchData()
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '生成二维码失败',
+      life: 3000
+    })
+  } finally {
+    generateLoading.value = false
+  }
+}
+
+// 导出生成的二维码
+const exportGeneratedCodes = () => {
+  if (generatedCodes.value.length === 0) return
+
+  const csvContent = [
+    ['二维码编号', '维护码', '类型', '生成时间'].join(','),
+    ...generatedCodes.value.map((code) =>
+      [
+        code.qrCode,
+        code.maintenanceCode,
+        getQrTypeName(generateForm.value.qrType),
+        formatDate(new Date())
+      ].join(',')
+    )
+  ].join('\n')
+
+  const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
+  const link = document.createElement('a')
+  link.href = URL.createObjectURL(blob)
+  link.download = `二维码列表_${formatDateShort(new Date())}.csv`
+  link.click()
+}
+
+// 按日期下载二维码
+const downloadByDate = async (date) => {
+  try {
+    const dateStr = formatDateShort(date)
+    const blob = await downloadQrCodesByDate(dateStr)
+
+    const link = document.createElement('a')
+    link.href = URL.createObjectURL(blob)
+    link.download = `二维码_${dateStr}.csv`
+    link.click()
+
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '下载成功',
+      life: 3000
+    })
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '下载失败',
+      life: 3000
+    })
+  }
+}
+
+// 查看扫描记录
+const viewScanRecords = async (qrCode) => {
+  try {
+    selectedQrCode.value = qrCode
+    const response = await getQrCodeScanRecords(qrCode.qrCode, 20)
+    scanRecords.value = response.data
+    scanRecordDialog.value = true
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取扫描记录失败',
+      life: 3000
+    })
+  }
+}
+
+// 复制到剪贴板
+const copyToClipboard = (text) => {
+  navigator.clipboard.writeText(text).then(() => {
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '已复制到剪贴板',
+      life: 2000
+    })
+  })
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<template>
+  <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
+    <!-- 数据表格 -->
+    <DataTable :value="tableData.content" :paginator="true"
+      paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
+      currentPageReportTemplate="{totalRecords} 条记录" :rows="tableData.metadata.size"
+      :rowsPerPageOptions="[10, 20, 50, 100]" :totalRecords="tableData.metadata.total" @page="handlePageChange" lazy
+      scrollable>
+      <template #header>
+        <div class="flex flex-wrap items-center gap-2">
+          <!-- 筛选条件 - 左侧 -->
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-search" />
+            </InputIcon>
+            <InputText v-model="filters.search" placeholder="二维码编号" size="small" class="w-35" />
+          </IconField>
+          <Select v-model="filters.qrType" :options="qrTypeOptions" optionLabel="label" optionValue="value"
+            placeholder="类型" class="w-30" size="small" />
+          <Select v-model="filters.isActivated" :options="activatedOptions" optionLabel="label" optionValue="value"
+            placeholder="激活状态" class="w-30" size="small" />
+          <DatePicker v-model="filters.startDate" placeholder="开始日期" dateFormat="yy-mm-dd" class="w-40" size="small" />
+          <DatePicker v-model="filters.endDate" placeholder="结束日期" dateFormat="yy-mm-dd" class="w-40" size="small" />
+          <Button icon="pi pi-search" @click="fetchData" label="查询" size="small" />
+          <Button icon="pi pi-refresh" @click="resetAndRefresh" label="刷新" size="small" />
+          <Button icon="pi pi-plus" @click="openGenerateDialog" label="生成二维码" severity="info" size="small" />
+          <Button icon="pi pi-download" @click="openBatchDownloadDialog" label="批量下载二维码" severity="danger"
+            size="small" />
+        </div>
+      </template>
+
+      <Column field="qrCode" header="二维码编号" style="min-width: 200px">
+        <template #body="slotProps">
+          <div class="flex items-center gap-2">
+            <code class="text-sm">{{ slotProps.data.qrCode }}</code>
+            <Button icon="pi pi-copy" size="small" text rounded @click="copyToClipboard(slotProps.data.qrCode)" />
+          </div>
+        </template>
+      </Column>
+      <Column field="qrType" header="类型" style="min-width: 120px">
+        <template #body="slotProps">
+          <Tag :value="getQrTypeName(slotProps.data.qrType)"
+            :severity="slotProps.data.qrType === 'person' ? 'info' : 'warn'" />
+        </template>
+      </Column>
+      <Column field="isActivated" header="状态" style="min-width: 100px">
+        <template #body="slotProps">
+          <Tag :value="getActivatedTag(slotProps.data.isActivated).label"
+            :severity="getActivatedTag(slotProps.data.isActivated).severity" />
+        </template>
+      </Column>
+      <Column field="scanCount" header="扫描次数" style="min-width: 100px">
+        <template #body="slotProps">
+          <span class="font-semibold">{{ slotProps.data.scanCount || 0 }}</span>
+        </template>
+      </Column>
+      <Column field="createdAt" header="生成时间" style="min-width: 180px">
+        <template #body="slotProps">
+          {{ formatDate(slotProps.data.createdAt) }}
+        </template>
+      </Column>
+      <Column header="操作" style="min-width: 150px">
+        <template #body="slotProps">
+          <div class="flex gap-1">
+            <Button icon="pi pi-chart-line" label="扫描记录" size="small" text style="white-space: nowrap"
+              @click="viewScanRecords(slotProps.data)" />
+            <Button icon="pi pi-download" severity="danger" size="small" text rounded v-tooltip.top="'下载'"
+              @click="downloadByDate(slotProps.data.createdAt)" />
+          </div>
+        </template>
+      </Column>
+    </DataTable>
+
+    <!-- 生成二维码对话框 -->
+    <Dialog v-model:visible="generateDialog" :modal="true" header="生成二维码" :style="{ width: '550px' }" position="center">
+      <div class="space-y-4">
+        <div class="field" style="margin-top: 10px;">
+          <FloatLabel variant="on">
+            <Select id="qrType" v-model="generateForm.qrType" :options="[
+              { label: '人员', value: 'person' },
+              { label: '宠物|物品', value: 'pet' }
+            ]" optionLabel="label" optionValue="value" fluid />
+            <label for="qrType">二维码类型</label>
+          </FloatLabel>
+        </div>
+
+        <div class="field" style="margin-top: 10px;">
+          <FloatLabel variant="on">
+            <InputNumber id="quantity" v-model="generateForm.quantity" :min="1" :max="1000" fluid />
+            <label for="quantity">生成数量 (1-1000)</label>
+          </FloatLabel>
+        </div>
+
+        <!-- 已生成的二维码列表 -->
+        <div v-if="generatedCodes.length > 0" class="mt-4">
+          <div class="flex justify-between items-center mb-2">
+            <h4 class="font-semibold">生成结果 ({{ generatedCodes.length }} 个)</h4>
+            <Button icon="pi pi-download" label="导出CSV" size="small" @click="exportGeneratedCodes" />
+          </div>
+          <div class="max-h-60 overflow-y-auto border rounded p-2">
+            <div v-for="(code, index) in generatedCodes" :key="index"
+              class="flex justify-between items-center py-2 border-b last:border-b-0">
+              <div>
+                <div class="text-sm font-mono">{{ code.qrCode }}</div>
+                <div class="text-xs text-gray-500">维护码: {{ code.maintenanceCode }}</div>
+              </div>
+              <Button icon="pi pi-copy" size="small" text
+                @click="copyToClipboard(`${code.qrCode},${code.maintenanceCode}`)" />
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <Button label="取消" severity="secondary" @click="generateDialog = false" :disabled="generateLoading" />
+        <Button label="生成" @click="handleGenerate" :loading="generateLoading" />
+      </template>
+    </Dialog>
+
+    <!-- 扫描记录对话框 -->
+    <Dialog v-model:visible="scanRecordDialog" :modal="true" header="扫描记录" :style="{ width: '800px' }"
+      position="center">
+      <div v-if="selectedQrCode" class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded">
+        <div class="text-sm text-gray-600 dark:text-gray-400">二维码编号</div>
+        <div class="font-mono font-semibold">{{ selectedQrCode.qrCode }}</div>
+        <div class="text-sm text-gray-600 dark:text-gray-400 mt-2">总扫描次数</div>
+        <div class="font-semibold text-lg">{{ selectedQrCode.scanCount || 0 }} 次</div>
+      </div>
+
+      <DataTable :value="scanRecords" scrollable scrollHeight="400px">
+        <Column field="scanTime" header="扫描时间" style="min-width: 180px">
+          <template #body="slotProps">
+            {{ formatDate(slotProps.data.scanTime) }}
+          </template>
+        </Column>
+        <Column field="address" header="地址" style="min-width: 200px">
+          <template #body="slotProps">
+            {{ slotProps.data.address || '-' }}
+          </template>
+        </Column>
+        <Column field="latitude" header="位置" style="min-width: 150px">
+          <template #body="slotProps">
+            <a v-if="slotProps.data.latitude && slotProps.data.longitude"
+              :href="`https://www.google.com/maps?q=${slotProps.data.latitude},${slotProps.data.longitude}`"
+              target="_blank" class="text-blue-600 hover:underline">
+              📍 查看地图
+            </a>
+            <span v-else>-</span>
+          </template>
+        </Column>
+        <Column field="ipAddress" header="IP地址" style="min-width: 150px">
+          <template #body="slotProps">
+            {{ slotProps.data.ipAddress || '-' }}
+          </template>
+        </Column>
+      </DataTable>
+
+      <template #footer>
+        <Button label="关闭" @click="scanRecordDialog = false" />
+      </template>
+    </Dialog>
+
+    <!-- 批量下载二维码对话框 -->
+    <Dialog v-model:visible="batchDownloadDialog" :modal="true" header="批量下载二维码" :style="{ width: '450px' }"
+      position="center">
+      <div class="space-y-4">
+        <div class="field" style="margin-top: 10px;">
+          <FloatLabel variant="on">
+            <DatePicker id="downloadDate" v-model="downloadDate" dateFormat="yy-mm-dd" fluid />
+            <label for="downloadDate">下载日期</label>
+          </FloatLabel>
+        </div>
+      </div>
+
+      <template #footer>
+        <Button label="取消" severity="secondary" @click="batchDownloadDialog = false" />
+        <Button label="下载" @click="handleBatchDownload" />
+      </template>
+    </Dialog>
+  </div>
+</template>

+ 470 - 0
src/views/ScanRecordView.vue

@@ -0,0 +1,470 @@
+<script setup>
+import { queryScanRecords } from '@/services/api'
+import { useDateFormat } from '@vueuse/core'
+import Button from 'primevue/button'
+import Column from 'primevue/column'
+import DataTable from 'primevue/datatable'
+import Dialog from 'primevue/dialog'
+import IconField from 'primevue/iconfield'
+import InputIcon from 'primevue/inputicon'
+import InputText from 'primevue/inputtext'
+import DatePicker from 'primevue/datepicker'
+import { useToast } from 'primevue/usetoast'
+import { onMounted, ref } from 'vue'
+
+const toast = useToast()
+
+// 表格数据
+const tableData = ref({
+  content: [],
+  metadata: {
+    page: 0,
+    size: 20,
+    total: 0
+  }
+})
+
+// 筛选条件
+const filters = ref({
+  qrCodeSearch: '',
+  startDate: null,
+  endDate: null
+})
+
+// 格式化日期
+const formatDate = (date) => {
+  if (!date) return '-'
+  return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
+}
+
+const formatDateShort = (date) => {
+  if (!date) return '-'
+  return useDateFormat(new Date(date), 'YYYY-MM-DD').value
+}
+
+// 获取数据
+const fetchData = async () => {
+  try {
+    const params = {
+      page: tableData.value.metadata.page,
+      pageSize: tableData.value.metadata.size
+    }
+
+    if (filters.value.startDate)
+      params.startDate = useDateFormat(filters.value.startDate, 'YYYY-MM-DD').value
+    if (filters.value.endDate)
+      params.endDate = useDateFormat(filters.value.endDate, 'YYYY-MM-DD').value
+    if (filters.value.qrCodeSearch?.trim()) params.qrCode = filters.value.qrCodeSearch.trim()
+
+    const response = await queryScanRecords(params)
+
+    tableData.value.content = response.content || []
+    tableData.value.metadata = {
+      page: (response.metadata?.page || 1) - 1, // 转换为 0-based
+      size: response.metadata?.size || 20,
+      total: response.metadata?.total || 0
+    }
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取数据失败',
+      life: 3000
+    })
+  }
+}
+
+// 分页
+const handlePageChange = (event) => {
+  tableData.value.metadata.page = event.page
+  tableData.value.metadata.size = event.rows
+  fetchData()
+}
+
+// 重置筛选并刷新
+const resetAndRefresh = () => {
+  filters.value = {
+    qrCodeSearch: '',
+    startDate: null,
+    endDate: null
+  }
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
+// 扫描记录详情对话框
+const recordDialog = ref(false)
+const scanRecordsData = ref({
+  content: [],
+  metadata: {
+    page: 1,
+    size: 20,
+    total: 0
+  }
+})
+const selectedQrCode = ref(null)
+const recordsLoading = ref(false)
+const recordFilters = ref({
+  startDate: null,
+  endDate: null
+})
+
+// 查看扫描记录(查看特定二维码的所有记录)
+const viewScanRecords = async (record) => {
+  // 从扫描记录中获取二维码编号
+  const qrCode = record.qrCode || record.qrCodeId
+  if (!qrCode) {
+    toast.add({
+      severity: 'warn',
+      summary: '提示',
+      detail: '无法获取二维码编号',
+      life: 3000
+    })
+    return
+  }
+
+  selectedQrCode.value = { qrCode }
+  recordFilters.value = {
+    startDate: null,
+    endDate: null
+  }
+  scanRecordsData.value.metadata.page = 1 // API 使用 1-based
+  recordDialog.value = true
+  await fetchScanRecords()
+}
+
+// 获取扫描记录数据
+const fetchScanRecords = async () => {
+  if (!selectedQrCode.value) return
+
+  recordsLoading.value = true
+  try {
+    const params = {
+      qrCode: selectedQrCode.value.qrCode,
+      page: scanRecordsData.value.metadata.page, // 已经是 1-based
+      pageSize: scanRecordsData.value.metadata.size
+    }
+
+    if (recordFilters.value.startDate) {
+      params.startDate = useDateFormat(recordFilters.value.startDate, 'YYYY-MM-DD').value
+    }
+    if (recordFilters.value.endDate) {
+      params.endDate = useDateFormat(recordFilters.value.endDate, 'YYYY-MM-DD').value
+    }
+
+    const response = await queryScanRecords(params)
+    scanRecordsData.value.content = response.content || []
+    scanRecordsData.value.metadata = {
+      page: response.metadata?.page || 1, // 保持 1-based(用于 API)
+      size: response.metadata?.size || 20,
+      total: response.metadata?.total || 0
+    }
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取扫描记录失败',
+      life: 3000
+    })
+  } finally {
+    recordsLoading.value = false
+  }
+}
+
+// 扫描记录分页处理
+const handleRecordPageChange = (event) => {
+  scanRecordsData.value.metadata.page = event.page + 1 // PrimeVue 使用 0-based,API 使用 1-based
+  scanRecordsData.value.metadata.size = event.rows
+  fetchScanRecords()
+}
+
+// 重置扫描记录筛选并刷新
+const resetRecordFilters = () => {
+  recordFilters.value = {
+    startDate: null,
+    endDate: null
+  }
+  scanRecordsData.value.metadata.page = 1 // API 使用 1-based
+  fetchScanRecords()
+}
+
+// 导出扫描记录(导出所有记录)
+const exportScanRecords = async () => {
+  if (!selectedQrCode.value) return
+
+  try {
+    recordsLoading.value = true
+    const allRecords = []
+    let currentPage = 1
+    const pageSize = 100 // 使用较大的页面大小以减少请求次数
+    let hasMore = true
+
+    // 获取所有记录
+    while (hasMore) {
+      const params = {
+        qrCode: selectedQrCode.value.qrCode,
+        page: currentPage,
+        pageSize: pageSize
+      }
+
+      if (recordFilters.value.startDate) {
+        params.startDate = useDateFormat(recordFilters.value.startDate, 'YYYY-MM-DD').value
+      }
+      if (recordFilters.value.endDate) {
+        params.endDate = useDateFormat(recordFilters.value.endDate, 'YYYY-MM-DD').value
+      }
+
+      const response = await queryScanRecords(params)
+      const records = response.content || []
+      allRecords.push(...records)
+
+      const total = response.metadata?.total || 0
+      hasMore = currentPage * pageSize < total
+      currentPage++
+    }
+
+    if (allRecords.length === 0) {
+      toast.add({
+        severity: 'warn',
+        summary: '提示',
+        detail: '没有可导出的记录',
+        life: 3000
+      })
+      return
+    }
+
+    // 生成 CSV
+    const csvContent = [
+      ['扫描时间', '地址', '纬度', '经度', 'IP地址', '设备信息'].join(','),
+      ...allRecords.map((record) =>
+        [
+          formatDate(record.scanTime),
+          record.address || '-',
+          record.latitude || '-',
+          record.longitude || '-',
+          record.ipAddress || '-',
+          (record.userAgent || '-').replace(/,/g, ';') // 替换逗号避免 CSV 格式问题
+        ].join(',')
+      )
+    ].join('\n')
+
+    const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
+    const link = document.createElement('a')
+    link.href = URL.createObjectURL(blob)
+    link.download = `扫描记录_${selectedQrCode.value.qrCode}_${formatDateShort(new Date())}.csv`
+    link.click()
+
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: `已导出 ${allRecords.length} 条记录`,
+      life: 3000
+    })
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '导出失败',
+      life: 3000
+    })
+  } finally {
+    recordsLoading.value = false
+  }
+}
+
+// 复制到剪贴板
+const copyToClipboard = (text) => {
+  navigator.clipboard.writeText(text).then(() => {
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '已复制到剪贴板',
+      life: 2000
+    })
+  })
+}
+
+// 在地图上查看
+const viewOnMap = (latitude, longitude) => {
+  if (latitude && longitude) {
+    window.open(`https://www.google.com/maps?q=${latitude},${longitude}`, '_blank')
+  }
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<template>
+  <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
+    <DataTable :value="tableData.content" :paginator="true"
+      paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
+      currentPageReportTemplate="{totalRecords} 条记录" :rows="tableData.metadata.size"
+      :rowsPerPageOptions="[10, 20, 50, 100]" :totalRecords="tableData.metadata.total"
+      :first="tableData.metadata.page * tableData.metadata.size" @page="handlePageChange" lazy scrollable>
+      <template #header>
+        <div class="flex flex-wrap items-center gap-2">
+          <!-- 筛选条件 -->
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-search" />
+            </InputIcon>
+            <InputText v-model="filters.qrCodeSearch" placeholder="搜索二维码编号" size="small" class="w-42" />
+          </IconField>
+          <DatePicker v-model="filters.startDate" placeholder="开始日期" dateFormat="yy-mm-dd" class="w-40" size="small" />
+          <DatePicker v-model="filters.endDate" placeholder="结束日期" dateFormat="yy-mm-dd" class="w-40" size="small" />
+          <Button icon="pi pi-search" @click="fetchData" label="查询" size="small" />
+          <Button icon="pi pi-refresh" @click="resetAndRefresh" label="刷新" size="small" />
+        </div>
+      </template>
+
+      <Column field="scanTime" header="扫描时间" style="min-width: 180px">
+        <template #body="slotProps">
+          <div class="flex items-center gap-2">
+            <i class="pi pi-clock text-gray-400"></i>
+            {{ formatDate(slotProps.data.scanTime) }}
+          </div>
+        </template>
+      </Column>
+      <Column field="qrCode" header="二维码编号" style="min-width: 200px">
+        <template #body="slotProps">
+          <div class="flex items-center gap-2">
+            <code class="text-sm">{{ slotProps.data.qrCode || slotProps.data.qrCodeId || '-' }}</code>
+            <Button v-if="slotProps.data.qrCode || slotProps.data.qrCodeId" icon="pi pi-copy" size="small" text rounded
+              @click="copyToClipboard(slotProps.data.qrCode || slotProps.data.qrCodeId)" />
+          </div>
+        </template>
+      </Column>
+      <Column field="address" header="地址" style="min-width: 250px">
+        <template #body="slotProps">
+          <div class="flex items-center gap-2">
+            <i class="pi pi-map-marker text-gray-400"></i>
+            <span>{{ slotProps.data.address || '未获取到地址' }}</span>
+          </div>
+        </template>
+      </Column>
+      <Column field="location" header="位置坐标" style="min-width: 150px">
+        <template #body="slotProps">
+          <div v-if="slotProps.data.latitude && slotProps.data.longitude">
+            <Button icon="pi pi-map" label="查看地图" size="small" text
+              @click="viewOnMap(slotProps.data.latitude, slotProps.data.longitude)" />
+            <div class="text-xs text-gray-500 mt-1">
+              {{ slotProps.data.latitude }}, {{ slotProps.data.longitude }}
+            </div>
+          </div>
+          <span v-else class="text-gray-400">-</span>
+        </template>
+      </Column>
+      <Column field="ipAddress" header="IP地址" style="min-width: 150px">
+        <template #body="slotProps">
+          <code class="text-xs">{{ slotProps.data.ipAddress || '-' }}</code>
+        </template>
+      </Column>
+      <Column field="userAgent" header="设备信息" style="min-width: 200px">
+        <template #body="slotProps">
+          <div class="text-xs text-gray-600 truncate" :title="slotProps.data.userAgent">
+            {{ slotProps.data.userAgent || '-' }}
+          </div>
+        </template>
+      </Column>
+      <Column header="操作" style="min-width: 120px">
+        <template #body="slotProps">
+          <Button icon="pi pi-list" label="查看该二维码所有记录" severity="info" size="small"
+            @click="viewScanRecords(slotProps.data)" />
+        </template>
+      </Column>
+    </DataTable>
+
+    <!-- 扫描记录详情对话框 -->
+    <Dialog v-model:visible="recordDialog" :modal="true" header="扫描记录详情" :style="{ width: '900px' }" position="center">
+      <!-- 二维码信息摘要 -->
+      <div v-if="selectedQrCode" class="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded">
+        <div class="grid grid-cols-2 md:grid-cols-3 gap-4">
+          <div>
+            <div class="text-sm text-gray-500">二维码编号</div>
+            <div class="font-mono font-semibold">{{ selectedQrCode.qrCode }}</div>
+          </div>
+          <div>
+            <div class="text-sm text-gray-500">当前显示</div>
+            <div class="font-semibold text-lg text-blue-600">
+              {{ scanRecordsData.metadata.total }} 条记录
+            </div>
+          </div>
+          <div>
+            <div class="text-sm text-gray-500">操作</div>
+            <Button icon="pi pi-download" label="导出" size="small" @click="exportScanRecords"
+              :disabled="scanRecordsData.metadata.total === 0" />
+          </div>
+        </div>
+      </div>
+
+      <!-- 扫描记录筛选 -->
+      <div class="mb-4 flex flex-wrap items-center gap-2">
+        <DatePicker v-model="recordFilters.startDate" placeholder="开始日期" dateFormat="yy-mm-dd" class="w-40"
+          size="small" />
+        <DatePicker v-model="recordFilters.endDate" placeholder="结束日期" dateFormat="yy-mm-dd" class="w-40"
+          size="small" />
+        <Button icon="pi pi-search" @click="fetchScanRecords" label="查询" size="small" />
+        <Button icon="pi pi-refresh" @click="resetRecordFilters" label="重置" size="small" severity="secondary" />
+      </div>
+
+      <!-- 扫描记录列表 -->
+      <DataTable :value="scanRecordsData.content" :loading="recordsLoading" :paginator="true"
+        paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
+        currentPageReportTemplate="{totalRecords} 条记录" :rows="scanRecordsData.metadata.size"
+        :rowsPerPageOptions="[10, 20, 50, 100]" :totalRecords="scanRecordsData.metadata.total"
+        :first="(scanRecordsData.metadata.page - 1) * scanRecordsData.metadata.size" @page="handleRecordPageChange" lazy
+        scrollable :emptyMessage="'暂无扫描记录'">
+        <Column field="scanTime" header="扫描时间" style="min-width: 180px">
+          <template #body="slotProps">
+            <div class="flex items-center gap-2">
+              <i class="pi pi-clock text-gray-400"></i>
+              {{ formatDate(slotProps.data.scanTime) }}
+            </div>
+          </template>
+        </Column>
+        <Column field="address" header="地址" style="min-width: 250px">
+          <template #body="slotProps">
+            <div class="flex items-center gap-2">
+              <i class="pi pi-map-marker text-gray-400"></i>
+              <span>{{ slotProps.data.address || '未获取到地址' }}</span>
+            </div>
+          </template>
+        </Column>
+        <Column field="location" header="位置坐标" style="min-width: 150px">
+          <template #body="slotProps">
+            <div v-if="slotProps.data.latitude && slotProps.data.longitude">
+              <Button icon="pi pi-map" label="查看地图" size="small" text
+                @click="viewOnMap(slotProps.data.latitude, slotProps.data.longitude)" />
+              <div class="text-xs text-gray-500 mt-1">
+                {{ slotProps.data.latitude }}, {{ slotProps.data.longitude }}
+              </div>
+            </div>
+            <span v-else class="text-gray-400">-</span>
+          </template>
+        </Column>
+        <Column field="ipAddress" header="IP地址" style="min-width: 150px">
+          <template #body="slotProps">
+            <code class="text-xs">{{ slotProps.data.ipAddress || '-' }}</code>
+          </template>
+        </Column>
+        <Column field="userAgent" header="设备信息" style="min-width: 200px">
+          <template #body="slotProps">
+            <div class="text-xs text-gray-600 truncate" :title="slotProps.data.userAgent">
+              {{ slotProps.data.userAgent || '-' }}
+            </div>
+          </template>
+        </Column>
+      </DataTable>
+
+      <template #footer>
+        <div class="flex justify-between">
+          <Button label="导出CSV" icon="pi pi-download" severity="secondary" @click="exportScanRecords"
+            :disabled="scanRecordsData.metadata.total === 0" :loading="recordsLoading" />
+          <Button label="关闭" @click="recordDialog = false" />
+        </div>
+      </template>
+    </Dialog>
+  </div>
+</template>

+ 0 - 21
src/views/SubMenuView.vue

@@ -1,21 +0,0 @@
-<template>
-  <div class="sub-menu-1-view">
-    <h1>子菜单</h1>
-    <p>这是子菜单的内容页面</p>
-  </div>
-</template>
-
-<script setup>
-</script>
-
-<style scoped>
-.sub-menu-1-view {
-  padding: 2rem;
-}
-
-h1 {
-  font-size: 2rem;
-  font-weight: bold;
-  margin-bottom: 1rem;
-}
-</style>

+ 41 - 15
src/views/UserView.vue

@@ -8,7 +8,7 @@ import Button from 'primevue/button'
 import Column from 'primevue/column'
 import DataTable from 'primevue/datatable'
 import Dialog from 'primevue/dialog'
-import Dropdown from 'primevue/dropdown'
+import Select from 'primevue/select'
 import FloatLabel from 'primevue/floatlabel'
 import IconField from 'primevue/iconfield'
 import InputIcon from 'primevue/inputicon'
@@ -28,10 +28,36 @@ const tableData = ref({
     total: 0
   }
 })
-const search = ref('')
+// 筛选条件
+const filters = ref({
+  search: ''
+})
+
+// 重置筛选并刷新
+const resetAndRefresh = () => {
+  filters.value = {
+    search: ''
+  }
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
 const fetchData = async () => {
   const response = await listUsersApi(tableData.value.metadata.page, tableData.value.metadata.size)
-  tableData.value = response
+  // 如果前端需要搜索,可以在这里过滤
+  let content = response.content || []
+  if (filters.value.search?.trim()) {
+    const searchTerm = filters.value.search.trim().toLowerCase()
+    content = content.filter(
+      (user) =>
+        user.name?.toLowerCase().includes(searchTerm) ||
+        user.role?.toLowerCase().includes(searchTerm)
+    )
+  }
+  tableData.value = {
+    ...response,
+    content
+  }
 }
 
 const handlePageChange = (event) => {
@@ -183,23 +209,23 @@ onMounted(() => {
       scrollable
     >
       <template #header>
-        <div class="flex flex-wrap items-center">
-          <Button icon="pi pi-refresh" @click="fetchData" label="刷新" size="small" />
+        <div class="flex flex-wrap items-center gap-2">
+          <!-- 筛选条件 - 左侧 -->
+          <IconField>
+            <InputIcon>
+              <i class="pi pi-search" />
+            </InputIcon>
+            <InputText v-model="filters.search" placeholder="搜索用户名" size="small" class="w-42" />
+          </IconField>
+          <Button icon="pi pi-search" @click="fetchData" label="查询" size="small" />
+          <Button icon="pi pi-refresh" @click="resetAndRefresh" label="刷新" size="small" />
           <Button
             icon="pi pi-plus"
             @click="openNewUserDialog"
             label="新增用户"
-            severity="success"
+            severity="info"
             size="small"
-            class="ml-2"
           />
-          <div class="flex-1"></div>
-          <IconField>
-            <InputIcon>
-              <i class="pi pi-search" />
-            </InputIcon>
-            <InputText v-model="search" placeholder="搜素" />
-          </IconField>
         </div>
       </template>
       <Column field="id" header="ID"></Column>
@@ -299,7 +325,7 @@ onMounted(() => {
 
         <div class="field mt-4">
           <FloatLabel variant="on">
-            <Dropdown
+            <Select
               id="role"
               name="role"
               v-model="userForm.role"