Ver Fonte

新增链接信息管理功能,包括API接口、路由、视图和相关枚举,更新.gitignore以排除docs目录

wuyi há 1 mês atrás
pai
commit
bdaee10bb1

+ 1 - 0
.gitignore

@@ -13,6 +13,7 @@ dist
 dist-ssr
 coverage
 *.local
+docs
 
 /cypress/videos/
 /cypress/screenshots/

+ 5 - 0
src/enums/index.js

@@ -36,6 +36,11 @@ export const QrCodeType = {
     label: '物品',
     icon: 'pi-box',
     severity: 'warn'
+  },
+  link: {
+    label: '链接',
+    icon: 'pi-link',
+    severity: 'help'
   }
 }
 

+ 6 - 0
src/router/index.js

@@ -63,6 +63,12 @@ const router = createRouter({
           component: () => import('@/views/GoodsInfoManageView.vue'),
           meta: { roles: ['admin'] }
         },
+        {
+          path: 'link-info-manage',
+          name: 'link-info-manage',
+          component: () => import('@/views/LinkInfoManageView.vue'),
+          meta: { roles: ['admin'] }
+        },
         {
           path: 'scan-record',
           name: 'scan-record',

+ 42 - 0
src/services/api.js

@@ -341,3 +341,45 @@ export const queryScanRecords = async (params) => {
   const response = await api.get('/scan/list', { params })
   return response.data
 }
+
+// ==================== 链接信息相关API ====================
+
+// 创建链接信息
+export const createLinkInfo = async (data) => {
+  const response = await api.post('/link/create', data)
+  return response.data
+}
+
+// 获取链接信息
+export const getLinkInfo = async (qrCode) => {
+  const response = await api.get('/link/get', {
+    params: { qrCode }
+  })
+  return response.data
+}
+
+// 更新链接信息(前台,需要维护码)
+export const updateLinkInfo = async (data) => {
+  const response = await api.put('/link/update', data)
+  return response.data
+}
+
+// 管理员更新链接信息
+export const adminUpdateLinkInfo = async (data) => {
+  const response = await api.post('/link/admin/update', data)
+  return response.data
+}
+
+// 查询链接信息列表(管理员)
+export const queryLinkInfo = async (params) => {
+  const response = await api.get('/link/admin/list', { params })
+  return response.data
+}
+
+// 管理员查询链接详情
+export const getLinkAdminDetail = async (qrCodeId) => {
+  const response = await api.get('/link/admin/detail', {
+    params: { qrCodeId }
+  })
+  return response.data
+}

+ 388 - 0
src/views/LinkInfoManageView.vue

@@ -0,0 +1,388 @@
+<script setup>
+import { queryLinkInfo, adminUpdateLinkInfo, getLinkAdminDetail, getQrCodeInfo } 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 Textarea from 'primevue/textarea'
+import InputSwitch from 'primevue/inputswitch'
+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({
+  jumpUrl: ''
+})
+
+// 格式化日期
+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 + 1, // API 文档说明 page 从 1 开始
+      pageSize: tableData.value.metadata.size
+    }
+
+    if (filters.value.jumpUrl) params.jumpUrl = filters.value.jumpUrl
+
+    const response = await queryLinkInfo(params)
+    const content = response?.content ?? []
+    const metadata = response?.metadata ?? {}
+
+    tableData.value.content = content
+    tableData.value.metadata = {
+      page: (metadata.page ?? params.page) - 1, // 转换为从 0 开始用于 DataTable
+      size: metadata.size ?? params.pageSize,
+      total: metadata.total ?? content.length
+    }
+  } 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 = {
+    jumpUrl: ''
+  }
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
+// 编辑对话框
+const editDialog = ref(false)
+const editForm = ref({
+  qrCodeId: null,
+  jumpUrl: '',
+  isVisible: true,
+  remark: ''
+})
+const editFormLoading = ref(false)
+
+// 表单验证
+const editFormResolver = zodResolver(
+  z.object({
+    jumpUrl: z.string().url({ message: '必须是有效的URL格式' }).optional().or(z.literal('')),
+    isVisible: z.boolean(),
+    remark: z.string().max(500, { message: '备注信息不能超过500字符' }).optional()
+  })
+)
+
+// 打开编辑对话框
+const openEditDialog = async (link) => {
+  editForm.value = {
+    qrCodeId: link.qrCodeId,
+    jumpUrl: link.jumpUrl || '',
+    isVisible: link.isVisible !== undefined ? link.isVisible : true,
+    remark: link.remark || ''
+  }
+  editDialog.value = true
+}
+
+// 保存编辑
+const saveEdit = async ({ valid, values }) => {
+  if (!valid) return
+
+  editFormLoading.value = true
+  try {
+    const submitData = {
+      qrCodeId: editForm.value.qrCodeId,
+      jumpUrl: values.jumpUrl || null,
+      isVisible: values.isVisible,
+      remark: values.remark || null
+    }
+
+    await adminUpdateLinkInfo(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 selectedLink = ref(null)
+const qrCodeDetail = ref(null)
+const detailLoading = ref(false)
+
+const viewDetail = async (link) => {
+  try {
+    detailDialog.value = true
+    detailLoading.value = true
+    selectedLink.value = null
+    qrCodeDetail.value = null
+
+    const qrCodeId = link.qrCodeId
+    const linkDetail = qrCodeId ? await getLinkAdminDetail(qrCodeId) : null
+    const qrInfo = qrCodeId ? await getQrCodeInfo(qrCodeId) : null
+
+    selectedLink.value = linkDetail || link
+    qrCodeDetail.value = qrInfo
+  } catch (error) {
+    detailDialog.value = false
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '获取详情失败',
+      life: 3000
+    })
+  } finally {
+    detailLoading.value = false
+  }
+}
+
+// 复制到剪贴板
+const copyToClipboard = (text) => {
+  navigator.clipboard.writeText(String(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-link" />
+            </InputIcon>
+            <InputText v-model="filters.jumpUrl" placeholder="跳转链接" size="small" class="w-48" />
+          </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="qrCodeId" header="二维码ID" style="min-width: 160px">
+        <template #body="slotProps">
+          <div class="flex items-center gap-2">
+            <code class="text-xs">{{ slotProps.data.qrCodeId || '-' }}</code>
+            <Button icon="pi pi-copy" size="small" text rounded
+              @click="copyToClipboard(slotProps.data.qrCodeId)"
+              :disabled="!slotProps.data.qrCodeId" />
+          </div>
+        </template>
+      </Column>
+      <Column field="jumpUrl" header="跳转链接" style="min-width: 250px">
+        <template #body="slotProps">
+          <a v-if="slotProps.data.jumpUrl" :href="slotProps.data.jumpUrl" target="_blank"
+            class="text-blue-600 hover:underline break-all">
+            {{ slotProps.data.jumpUrl }}
+          </a>
+          <span v-else class="text-gray-400">-</span>
+        </template>
+      </Column>
+      <Column field="remark" header="备注" style="min-width: 220px">
+        <template #body="slotProps">
+          <span class="whitespace-pre-line break-words">{{ slotProps.data.remark || '-' }}</span>
+        </template>
+      </Column>
+      <Column field="createdAt" header="创建时间" style="min-width: 180px">
+        <template #body="slotProps">
+          {{ formatDate(slotProps.data.createdAt) }}
+        </template>
+      </Column>
+      <Column field="updatedAt" header="更新时间" style="min-width: 180px">
+        <template #body="slotProps">
+          {{ formatDate(slotProps.data.updatedAt) }}
+        </template>
+      </Column>
+      <Column header="操作" style="min-width: 150px" frozen alignFrozen="right"
+        :pt="{ columnHeaderContent: { class: 'justify-center' } }">
+        <template #body="slotProps">
+          <div class="flex gap-1 justify-center">
+            <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 mt-4">
+        <div class="field">
+          <FloatLabel variant="on">
+            <InputText id="jumpUrl" name="jumpUrl" v-model="editForm.jumpUrl" fluid />
+            <label for="jumpUrl">跳转链接</label>
+          </FloatLabel>
+          <Message v-if="$form.jumpUrl?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.jumpUrl.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <div class="flex items-center gap-2">
+            <InputSwitch id="isVisible" name="isVisible" v-model="editForm.isVisible" />
+            <label for="isVisible" class="cursor-pointer">是否可见</label>
+          </div>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <Textarea id="remark" name="remark" v-model="editForm.remark" rows="3" autoResize fluid />
+            <label for="remark">备注</label>
+          </FloatLabel>
+          <Message v-if="$form.remark?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.remark.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: '750px' }" position="center">
+      <div v-if="detailLoading" class="py-10 text-center text-gray-500">详情加载中...</div>
+      <div v-else-if="selectedLink" class="space-y-4">
+        <!-- 基本信息 -->
+        <div class="border rounded p-4">
+          <h4 class="font-semibold mb-3">基本信息</h4>
+          <div class="grid grid-cols-2 gap-4">
+            <div>
+              <div class="text-sm text-gray-500">二维码ID</div>
+              <div class="font-mono text-sm">{{ selectedLink.qrCodeId || '-' }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">可见性</div>
+              <div>
+                <Tag :value="selectedLink.isVisible ? '可见' : '隐藏'"
+                  :severity="selectedLink.isVisible ? 'success' : 'secondary'" />
+              </div>
+            </div>
+            <div class="col-span-2">
+              <div class="text-sm text-gray-500">跳转链接</div>
+              <div>
+                <a v-if="selectedLink.jumpUrl" :href="selectedLink.jumpUrl" target="_blank"
+                  class="text-blue-600 hover:underline break-all">
+                  {{ selectedLink.jumpUrl }}
+                </a>
+                <span v-else class="text-gray-400">-</span>
+              </div>
+            </div>
+            <div v-if="selectedLink.remark" class="col-span-2">
+              <div class="text-sm text-gray-500">备注</div>
+              <div class="whitespace-pre-wrap">{{ selectedLink.remark }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">创建时间</div>
+              <div>{{ formatDate(selectedLink.createdAt) }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">更新时间</div>
+              <div>{{ formatDate(selectedLink.updatedAt) }}</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-4">
+            <div>
+              <div class="text-sm text-gray-500">二维码编号</div>
+              <div class="font-mono text-sm break-all">{{ qrCodeDetail.qrCode || '-' }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">类型</div>
+              <div class="font-medium">{{ qrCodeDetail.qrType || '-' }}</div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">激活状态</div>
+              <div>
+                <Tag :value="qrCodeDetail.isActivated ? '已激活' : '未激活'"
+                  :severity="qrCodeDetail.isActivated ? 'success' : 'secondary'" />
+              </div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">扫描次数</div>
+              <div class="font-semibold text-lg text-blue-600">{{ qrCodeDetail.scanCount || 0 }} 次</div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <Button label="关闭" @click="detailDialog = false" />
+      </template>
+    </Dialog>
+  </div>
+</template>
+

+ 6 - 0
src/views/MainView.vue

@@ -66,6 +66,12 @@ const allNavItems = [
         name: 'goods-info-manage',
         roles: ['admin']
       },
+      {
+        label: '链接信息管理',
+        icon: 'pi pi-fw pi-link',
+        name: 'link-info-manage',
+        roles: ['admin']
+      },
       {
         label: '扫描记录',
         icon: 'pi pi-fw pi-chart-line',

+ 29 - 2
src/views/QrCodeManageView.vue

@@ -50,7 +50,8 @@ const qrTypeOptions = [
   { label: '全部', value: null },
   { label: '人员', value: 'person' },
   { label: '宠物', value: 'pet' },
-  { label: '物品', value: 'goods' }
+  { label: '物品', value: 'goods' },
+  { label: '链接', value: 'link' }
 ]
 
 // 激活状态选项
@@ -601,7 +602,8 @@ onMounted(() => {
             <Select id="qrType" v-model="generateForm.qrType" :options="[
               { label: '人员', value: 'person' },
               { label: '宠物', value: 'pet' },
-              { label: '物品', value: 'goods' }
+              { label: '物品', value: 'goods' },
+              { label: '链接', value: 'link' }
             ]" optionLabel="label" optionValue="value" fluid />
             <label for="qrType">二维码类型</label>
           </FloatLabel>
@@ -931,6 +933,31 @@ onMounted(() => {
               </div>
             </div>
           </div>
+
+          <!-- 链接信息 -->
+          <div v-else-if="qrCodeDetail.qrType === 'link'" class="grid grid-cols-2 gap-4">
+            <div class="col-span-2">
+              <div class="text-sm text-gray-500">跳转链接</div>
+              <div>
+                <a v-if="qrCodeDetail.info.jumpUrl" :href="qrCodeDetail.info.jumpUrl" target="_blank"
+                  class="text-blue-600 hover:underline break-all">
+                  {{ qrCodeDetail.info.jumpUrl }}
+                </a>
+                <span v-else class="text-gray-400">-</span>
+              </div>
+            </div>
+            <div>
+              <div class="text-sm text-gray-500">可见性</div>
+              <div>
+                <Tag :value="qrCodeDetail.info.isVisible ? '可见' : '隐藏'"
+                  :severity="qrCodeDetail.info.isVisible ? 'success' : 'secondary'" />
+              </div>
+            </div>
+            <div v-if="qrCodeDetail.info.remark" class="col-span-2">
+              <div class="text-sm text-gray-500">备注</div>
+              <div class="whitespace-pre-wrap">{{ qrCodeDetail.info.remark }}</div>
+            </div>
+          </div>
         </div>
 
         <!-- 未激活提示 -->