|
|
@@ -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>
|
|
|
+
|