|
|
@@ -0,0 +1,746 @@
|
|
|
+<script setup>
|
|
|
+import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
|
+import { useRoute, useRouter } from 'vue-router'
|
|
|
+import { useToast } from 'primevue/usetoast'
|
|
|
+import Dialog from 'primevue/dialog'
|
|
|
+import {
|
|
|
+ fetchQrInfoApi,
|
|
|
+ updateLinkInfoApi,
|
|
|
+ verifyMaintenanceCodeApi,
|
|
|
+ fetchRecentScanRecordsApi
|
|
|
+} from '@/services/api'
|
|
|
+
|
|
|
+const route = useRoute()
|
|
|
+const router = useRouter()
|
|
|
+const toast = useToast()
|
|
|
+
|
|
|
+// Pull QR code from route params
|
|
|
+const qrCode = ref(route.params.qrCode?.toString().trim() || '')
|
|
|
+const queryInput = ref(qrCode.value)
|
|
|
+
|
|
|
+const loading = reactive({
|
|
|
+ info: false,
|
|
|
+ saving: false,
|
|
|
+ verifying: false
|
|
|
+})
|
|
|
+
|
|
|
+const qrDetail = ref(null)
|
|
|
+const linkInfo = ref(null)
|
|
|
+const infoStatus = reactive({
|
|
|
+ state: qrCode.value ? 'loading' : 'idle',
|
|
|
+ message: ''
|
|
|
+})
|
|
|
+
|
|
|
+const maintenanceCode = ref('')
|
|
|
+const maintenancePassed = ref(false)
|
|
|
+const showMaintenanceDialog = ref(false)
|
|
|
+const isEditing = ref(false)
|
|
|
+const showScanRecordsDialog = ref(false)
|
|
|
+const showScanRecordsMaintenanceDialog = ref(false)
|
|
|
+const scanRecordsMaintenanceCode = ref('')
|
|
|
+const verifiedMaintenanceCode = ref('')
|
|
|
+const scanRecords = ref(null)
|
|
|
+const loadingScanRecords = ref(false)
|
|
|
+
|
|
|
+const formData = reactive({
|
|
|
+ jumpUrl: '',
|
|
|
+ remark: ''
|
|
|
+})
|
|
|
+
|
|
|
+const hasLinkInfo = computed(() => Boolean(linkInfo.value))
|
|
|
+const isFirstFill = computed(() => {
|
|
|
+ // Prioritize isVisible check, if not visible then not considered first use
|
|
|
+ if (qrDetail.value?.isVisible === false) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ // 如果 isActivated 为 false 或 undefined,则认为是第一次填写
|
|
|
+ return Boolean(qrDetail.value) && !qrDetail.value?.isActivated
|
|
|
+})
|
|
|
+
|
|
|
+const parseError = (error) => {
|
|
|
+ return (
|
|
|
+ error?.response?.data?.message ||
|
|
|
+ error?.data?.message ||
|
|
|
+ error?.detail ||
|
|
|
+ error?.message ||
|
|
|
+ error?.error ||
|
|
|
+ (typeof error === 'string' ? error : '') ||
|
|
|
+ 'Request failed'
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+const setDocumentTitle = () => {
|
|
|
+ const name = linkInfo.value?.jumpUrl || 'Link Information'
|
|
|
+ document.title = `${name} | Link QR`
|
|
|
+}
|
|
|
+
|
|
|
+const fetchQrDetails = async () => {
|
|
|
+ if (!qrCode.value) return
|
|
|
+ loading.info = true
|
|
|
+ infoStatus.state = 'loading'
|
|
|
+ infoStatus.message = ''
|
|
|
+ try {
|
|
|
+ const data = await fetchQrInfoApi(qrCode.value)
|
|
|
+ qrDetail.value = data
|
|
|
+ if (data.isVisible === false) {
|
|
|
+ infoStatus.state = 'notVisible'
|
|
|
+ infoStatus.message = 'Link information is not visible'
|
|
|
+ linkInfo.value = null
|
|
|
+ resetForm()
|
|
|
+ setDocumentTitle()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // Extract link information
|
|
|
+ linkInfo.value = data.info || null
|
|
|
+ if (linkInfo.value) {
|
|
|
+ formData.jumpUrl = linkInfo.value.jumpUrl || ''
|
|
|
+ formData.remark = linkInfo.value.remark || ''
|
|
|
+ }
|
|
|
+ infoStatus.state = 'ready'
|
|
|
+ setDocumentTitle()
|
|
|
+ } catch (error) {
|
|
|
+ infoStatus.state = 'error'
|
|
|
+ infoStatus.message = parseError(error)
|
|
|
+ } finally {
|
|
|
+ loading.info = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleQrSubmit = () => {
|
|
|
+ if (!queryInput.value.trim()) {
|
|
|
+ toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a QR code first.', life: 2600 })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ router.push({ name: 'jumpWithCode', params: { qrCode: queryInput.value.trim() } })
|
|
|
+}
|
|
|
+
|
|
|
+const handleVerifyMaintenance = async () => {
|
|
|
+ if (!qrCode.value || !maintenanceCode.value) {
|
|
|
+ toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter the maintenance code.', life: 2400 })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ loading.verifying = true
|
|
|
+ try {
|
|
|
+ await verifyMaintenanceCodeApi({
|
|
|
+ qrCode: qrCode.value,
|
|
|
+ maintenanceCode: maintenanceCode.value
|
|
|
+ })
|
|
|
+ maintenancePassed.value = true
|
|
|
+ showMaintenanceDialog.value = false
|
|
|
+ isEditing.value = true
|
|
|
+ toast.add({ severity: 'success', summary: 'Verified', detail: 'Maintenance editing unlocked.', life: 2600 })
|
|
|
+ // Scroll to the form the first time we collect data
|
|
|
+ if (isFirstFill.value) {
|
|
|
+ setTimeout(() => {
|
|
|
+ const el = document.getElementById('link-form-section')
|
|
|
+ if (el) {
|
|
|
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
|
+ }
|
|
|
+ }, 300)
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ maintenancePassed.value = false
|
|
|
+ toast.add({ severity: 'error', summary: 'Verification failed', detail: parseError(error), life: 3200 })
|
|
|
+ } finally {
|
|
|
+ loading.verifying = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const resetForm = (source = null) => {
|
|
|
+ if (source) {
|
|
|
+ formData.jumpUrl = source.jumpUrl || ''
|
|
|
+ formData.remark = source.remark || ''
|
|
|
+ } else {
|
|
|
+ formData.jumpUrl = ''
|
|
|
+ formData.remark = ''
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// URL validation function
|
|
|
+const isValidUrl = (url) => {
|
|
|
+ if (!url || !url.trim()) return true // Empty value is considered valid (optional field)
|
|
|
+ try {
|
|
|
+ const urlObj = new URL(url)
|
|
|
+ return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Form validation
|
|
|
+const validateForm = () => {
|
|
|
+ if (formData.jumpUrl && formData.jumpUrl.trim()) {
|
|
|
+ if (formData.jumpUrl.length > 2000) {
|
|
|
+ toast.add({ severity: 'warn', summary: 'Notice', detail: 'Jump URL cannot exceed 2000 characters.', life: 2400 })
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (!isValidUrl(formData.jumpUrl)) {
|
|
|
+ toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a valid URL (must start with http:// or https://).', life: 2400 })
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (formData.remark && formData.remark.length > 500) {
|
|
|
+ toast.add({ severity: 'warn', summary: 'Notice', detail: 'Remark cannot exceed 500 characters.', life: 2400 })
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+const handleSaveLink = async () => {
|
|
|
+ if (!qrCode.value) return
|
|
|
+ if (!validateForm()) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ loading.saving = true
|
|
|
+ try {
|
|
|
+ const payload = {
|
|
|
+ qrCode: qrCode.value,
|
|
|
+ maintenanceCode: maintenanceCode.value
|
|
|
+ }
|
|
|
+
|
|
|
+ // Only add optional fields with values
|
|
|
+ if (formData.jumpUrl && formData.jumpUrl.trim()) {
|
|
|
+ payload.jumpUrl = formData.jumpUrl.trim()
|
|
|
+ }
|
|
|
+
|
|
|
+ // Default to visible
|
|
|
+ payload.isVisible = true
|
|
|
+
|
|
|
+ if (formData.remark && formData.remark.trim()) {
|
|
|
+ payload.remark = formData.remark.trim()
|
|
|
+ }
|
|
|
+
|
|
|
+ const response = await updateLinkInfoApi(payload)
|
|
|
+
|
|
|
+ toast.add({
|
|
|
+ severity: 'success',
|
|
|
+ summary: 'Saved',
|
|
|
+ detail: response.message || 'Link information has been updated.',
|
|
|
+ life: 3000
|
|
|
+ })
|
|
|
+
|
|
|
+ await fetchQrDetails()
|
|
|
+ isEditing.value = false
|
|
|
+ maintenancePassed.value = false
|
|
|
+ maintenanceCode.value = ''
|
|
|
+ } catch (error) {
|
|
|
+ toast.add({ severity: 'error', summary: 'Save failed', detail: parseError(error), life: 3200 })
|
|
|
+ } finally {
|
|
|
+ loading.saving = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleOpenScanRecords = () => {
|
|
|
+ if (!qrCode.value) {
|
|
|
+ toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a QR code first.', life: 2400 })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ showScanRecordsMaintenanceDialog.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const fetchScanRecords = async (maintenanceCode) => {
|
|
|
+ if (!qrCode.value || !maintenanceCode) return
|
|
|
+ loadingScanRecords.value = true
|
|
|
+ try {
|
|
|
+ const data = await fetchRecentScanRecordsApi(qrCode.value, maintenanceCode)
|
|
|
+ scanRecords.value = data
|
|
|
+ verifiedMaintenanceCode.value = maintenanceCode
|
|
|
+ showScanRecordsMaintenanceDialog.value = false
|
|
|
+ showScanRecordsDialog.value = true
|
|
|
+ scanRecordsMaintenanceCode.value = ''
|
|
|
+ } catch (error) {
|
|
|
+ toast.add({ severity: 'error', summary: 'Failed', detail: parseError(error), life: 3200 })
|
|
|
+ scanRecords.value = null
|
|
|
+ } finally {
|
|
|
+ loadingScanRecords.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleVerifyScanRecordsMaintenance = async () => {
|
|
|
+ if (!qrCode.value || !scanRecordsMaintenanceCode.value) {
|
|
|
+ toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter the maintenance code.', life: 2400 })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await fetchScanRecords(scanRecordsMaintenanceCode.value)
|
|
|
+}
|
|
|
+
|
|
|
+const formatDateTime = (dateString) => {
|
|
|
+ if (!dateString) return '-'
|
|
|
+ const date = new Date(dateString)
|
|
|
+ return date.toLocaleString('en-US', {
|
|
|
+ year: 'numeric',
|
|
|
+ month: '2-digit',
|
|
|
+ day: '2-digit',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ second: '2-digit'
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const openRecordLocation = (record) => {
|
|
|
+ if (record.latitude && record.longitude) {
|
|
|
+ window.open(`https://www.google.com/maps?q=${record.latitude},${record.longitude}`, '_blank')
|
|
|
+ } else if (record.address) {
|
|
|
+ window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(record.address)}`, '_blank')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const openJumpUrl = () => {
|
|
|
+ if (linkInfo.value?.jumpUrl) {
|
|
|
+ window.open(linkInfo.value.jumpUrl, '_blank')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const copyInfo = async (value, label = 'info') => {
|
|
|
+ const content = value?.toString().trim()
|
|
|
+ if (!content) {
|
|
|
+ toast.add({ severity: 'info', summary: 'Notice', detail: `Nothing to copy for ${label}.`, life: 2200 })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ if (navigator?.clipboard?.writeText) {
|
|
|
+ await navigator.clipboard.writeText(content)
|
|
|
+ } else {
|
|
|
+ const textarea = document.createElement('textarea')
|
|
|
+ textarea.value = content
|
|
|
+ textarea.style.position = 'fixed'
|
|
|
+ textarea.style.opacity = '0'
|
|
|
+ document.body.appendChild(textarea)
|
|
|
+ textarea.focus()
|
|
|
+ textarea.select()
|
|
|
+ document.execCommand('copy')
|
|
|
+ document.body.removeChild(textarea)
|
|
|
+ }
|
|
|
+ toast.add({ severity: 'success', summary: 'Copied', detail: `${label} copied to clipboard.`, life: 2000 })
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Copy failed', error)
|
|
|
+ toast.add({ severity: 'error', summary: 'Copy failed', detail: 'Please copy manually.', life: 2200 })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const resetPageState = () => {
|
|
|
+ qrDetail.value = null
|
|
|
+ linkInfo.value = null
|
|
|
+ maintenanceCode.value = ''
|
|
|
+ maintenancePassed.value = false
|
|
|
+ showMaintenanceDialog.value = false
|
|
|
+ isEditing.value = false
|
|
|
+ infoStatus.state = qrCode.value ? 'loading' : 'idle'
|
|
|
+ infoStatus.message = ''
|
|
|
+ resetForm()
|
|
|
+}
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => route.params.qrCode,
|
|
|
+ (value) => {
|
|
|
+ const normalized = value?.toString().trim() || ''
|
|
|
+ queryInput.value = normalized
|
|
|
+ qrCode.value = normalized
|
|
|
+ resetPageState()
|
|
|
+ if (normalized) {
|
|
|
+ fetchQrDetails()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+)
|
|
|
+
|
|
|
+watch(linkInfo, () => {
|
|
|
+ resetForm(linkInfo.value)
|
|
|
+ setDocumentTitle()
|
|
|
+})
|
|
|
+
|
|
|
+// Auto open verification dialog on first fill
|
|
|
+watch(isFirstFill, (value) => {
|
|
|
+ if (value && !maintenancePassed.value) {
|
|
|
+ showMaintenanceDialog.value = true
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ if (!qrCode.value) {
|
|
|
+ setDocumentTitle()
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="jump-page min-h-screen bg-slate-950 text-slate-100">
|
|
|
+ <div class="relative isolate px-4 py-10 sm:px-6 lg:px-8">
|
|
|
+ <div
|
|
|
+ class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-96 bg-gradient-to-br from-cyan-500/20 via-indigo-500/10 to-transparent blur-3xl" />
|
|
|
+ <div class="mx-auto max-w-6xl space-y-8">
|
|
|
+ <header class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
|
+ <div>
|
|
|
+ <p class="text-xs uppercase tracking-[0.4em] text-cyan-200">Link information</p>
|
|
|
+ <h1 class="mt-2 text-3xl font-semibold text-white sm:text-4xl">
|
|
|
+ Link Card
|
|
|
+ </h1>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <section v-if="!qrCode"
|
|
|
+ class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-xl shadow-cyan-500/10 backdrop-blur">
|
|
|
+ <div class="flex items-start gap-4">
|
|
|
+ <div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-cyan-500/20">
|
|
|
+ <i class="pi pi-link text-2xl text-cyan-300" />
|
|
|
+ </div>
|
|
|
+ <div class="flex-1">
|
|
|
+ <p class="text-lg font-semibold text-white">Welcome to the Link QR system</p>
|
|
|
+ <p class="mt-2 text-sm text-slate-300">
|
|
|
+ Scan the QR code or enter its value below to continue.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-6 flex flex-col gap-4 sm:flex-row sm:items-start">
|
|
|
+ <div class="flex-1 space-y-2">
|
|
|
+ <input v-model="queryInput" type="text" placeholder="Enter QR code"
|
|
|
+ class="w-full rounded-2xl border border-white/20 bg-white/5 px-5 py-3.5 text-base text-white placeholder:text-slate-400 backdrop-blur-sm transition-all duration-200 focus:border-cyan-400 focus:bg-white/10 focus:outline-none focus:ring-2 focus:ring-cyan-400/30"
|
|
|
+ @keyup.enter="handleQrSubmit" />
|
|
|
+ <div class="flex items-center gap-2 px-1">
|
|
|
+ <svg class="h-4 w-4 flex-shrink-0 text-cyan-400/70" fill="none" viewBox="0 0 24 24"
|
|
|
+ stroke="currentColor">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
+ </svg>
|
|
|
+ <span class="text-sm text-slate-400/90">Example: <span
|
|
|
+ class="font-mono text-slate-300/80">QRMIH76J350CE2CF6150A51F4E</span></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button type="button"
|
|
|
+ class="rounded-2xl bg-gradient-to-r from-cyan-400 to-blue-400 px-8 py-3.5 text-base font-semibold text-slate-900 shadow-lg shadow-cyan-500/30 transition-all duration-200 hover:scale-105 hover:shadow-xl hover:shadow-cyan-500/40 sm:whitespace-nowrap"
|
|
|
+ @click="handleQrSubmit">
|
|
|
+ View info
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section v-else class="space-y-6">
|
|
|
+ <div v-if="infoStatus.state === 'error'"
|
|
|
+ class="rounded-3xl border border-red-500/40 bg-red-500/10 p-5">
|
|
|
+ <p class="text-sm text-red-100">{{ infoStatus.message }}</p>
|
|
|
+ <p class="mt-2 text-xs text-red-200">Please double-check the QR code or reach out for assistance.</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="infoStatus.state === 'notVisible'"
|
|
|
+ class="rounded-3xl border border-amber-500/40 bg-amber-500/10 p-6">
|
|
|
+ <div class="flex items-start gap-4">
|
|
|
+ <div
|
|
|
+ class="flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500/20 flex-shrink-0">
|
|
|
+ <i class="pi pi-eye-slash text-2xl text-amber-300" />
|
|
|
+ </div>
|
|
|
+ <div class="flex-1">
|
|
|
+ <p class="text-lg font-semibold text-amber-100">{{ infoStatus.message }}</p>
|
|
|
+ <p class="mt-2 text-sm text-amber-200">This link information has been set to invisible and cannot be viewed.</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="loading.info"
|
|
|
+ class="rounded-3xl border border-white/10 bg-white/5 p-6 text-base text-white">
|
|
|
+ Loading link information...
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 链接信息展示 -->
|
|
|
+ <div v-if="infoStatus.state === 'ready' && qrDetail && (maintenancePassed || hasLinkInfo)"
|
|
|
+ class="space-y-6">
|
|
|
+ <div v-if="hasLinkInfo && !isEditing"
|
|
|
+ class="rounded-3xl border border-white/10 bg-white text-slate-900 shadow-2xl shadow-cyan-500/10 relative">
|
|
|
+ <div class="space-y-6 p-6">
|
|
|
+ <!-- 跳转链接卡片 -->
|
|
|
+ <div
|
|
|
+ class="rounded-2xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-5 transition hover:shadow-md">
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <div
|
|
|
+ class="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 shadow-inner">
|
|
|
+ <i class="pi pi-link text-lg text-emerald-600" />
|
|
|
+ </div>
|
|
|
+ <p class="text-[11px] font-semibold uppercase tracking-[0.35em] text-slate-500">
|
|
|
+ JUMP URL
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div class="mt-3">
|
|
|
+ <p v-if="linkInfo.jumpUrl"
|
|
|
+ class="cursor-pointer text-lg font-semibold text-slate-800 break-all mb-4"
|
|
|
+ title="Click to copy link" @click="copyInfo(linkInfo.jumpUrl, 'Link')">
|
|
|
+ {{ linkInfo.jumpUrl }}
|
|
|
+ </p>
|
|
|
+ <p v-else class="text-sm text-slate-500 mb-4">Not set</p>
|
|
|
+ <button v-if="linkInfo.jumpUrl" type="button"
|
|
|
+ class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100"
|
|
|
+ @click="openJumpUrl">
|
|
|
+ <i class="pi pi-external-link" /> Open link
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 备注信息卡片 -->
|
|
|
+ <div v-if="linkInfo.remark"
|
|
|
+ class="rounded-2xl border border-cyan-200/50 bg-gradient-to-br from-cyan-50/80 to-white p-5">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="flex h-8 w-8 items-center justify-center rounded-full bg-cyan-100">
|
|
|
+ <i class="pi pi-info-circle text-sm text-cyan-600" />
|
|
|
+ </div>
|
|
|
+ <p class="text-xs font-medium uppercase tracking-wider text-slate-500">REMARK</p>
|
|
|
+ </div>
|
|
|
+ <div class="mt-3">
|
|
|
+ <p class="whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
|
|
|
+ {{ linkInfo.remark }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 操作按钮 -->
|
|
|
+ <div v-if="!isEditing" class="pt-4 space-y-3">
|
|
|
+ <button type="button"
|
|
|
+ class="w-full flex items-center justify-center gap-2 rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-yellow-700 transition-all duration-200 hover:border-slate-400 hover:shadow-md"
|
|
|
+ @click="handleOpenScanRecords">
|
|
|
+ <i class="pi pi-history text-xs" />
|
|
|
+ SCAN RECORDS
|
|
|
+ </button>
|
|
|
+ <button type="button"
|
|
|
+ class="w-full flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 transition-all duration-200 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md"
|
|
|
+ @click="showMaintenanceDialog = true">
|
|
|
+ <i class="pi pi-pencil text-xs" />
|
|
|
+ EDIT INFORMATION
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <!-- 编辑表单 -->
|
|
|
+ <section v-if="qrCode && maintenancePassed" id="link-form-section"
|
|
|
+ class="rounded-3xl border border-white/10 bg-white p-6 text-slate-900 shadow-2xl shadow-cyan-500/10">
|
|
|
+ <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
|
+ <div>
|
|
|
+ <p class="text-xs uppercase tracking-[0.3em] text-slate-400">Link setup</p>
|
|
|
+ <h2 class="mt-1 text-2xl font-semibold text-slate-900">
|
|
|
+ {{ linkInfo ? 'Update link' : 'First-time setup' }}
|
|
|
+ </h2>
|
|
|
+ </div>
|
|
|
+ <p class="text-sm text-slate-500">
|
|
|
+ QR Code: <span class="font-mono text-slate-700">{{ qrCode }}</span>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="isFirstFill" class="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
|
|
+ <div class="flex items-start gap-3">
|
|
|
+ <i class="pi pi-info-circle text-emerald-600" />
|
|
|
+ <div class="flex-1 text-sm text-emerald-700">
|
|
|
+ <p class="font-semibold">First-time tip</p>
|
|
|
+ <p class="mt-1">
|
|
|
+ This is the first record for this QR code. Once submitted, it becomes active. Keep the maintenance code safe for future edits.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="mt-4 rounded-2xl border border-cyan-200 bg-cyan-50 p-4">
|
|
|
+ <div class="flex items-start gap-3">
|
|
|
+ <i class="pi pi-check-circle text-cyan-600" />
|
|
|
+ <div class="flex-1 text-sm text-cyan-700">
|
|
|
+ <p class="font-semibold">Edit mode</p>
|
|
|
+ <p class="mt-1">
|
|
|
+ Maintenance code verified. You can now edit the link information—remember to save your changes.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <form class="mt-6 space-y-6" @submit.prevent="handleSaveLink">
|
|
|
+ <!-- Jump URL -->
|
|
|
+ <div class="space-y-2">
|
|
|
+ <label class="block text-sm font-medium text-slate-700">
|
|
|
+ Jump URL
|
|
|
+ </label>
|
|
|
+ <input v-model="formData.jumpUrl" type="url" placeholder="https://example.com"
|
|
|
+ maxlength="2000"
|
|
|
+ class="w-full rounded-2xl border border-slate-300 bg-white px-5 py-3.5 text-base text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-cyan-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/30" />
|
|
|
+ <p class="text-xs text-slate-500">
|
|
|
+ Optional, max 2000 characters, must be a valid URL (must start with http:// or https://)
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Remark -->
|
|
|
+ <div class="space-y-2">
|
|
|
+ <label class="block text-sm font-medium text-slate-700">
|
|
|
+ Remark
|
|
|
+ </label>
|
|
|
+ <textarea v-model="formData.remark" placeholder="Enter remark information" maxlength="500" rows="4"
|
|
|
+ class="w-full rounded-2xl border border-slate-300 bg-white px-5 py-3.5 text-base text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-cyan-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/30 resize-none"></textarea>
|
|
|
+ <p class="text-xs text-slate-500">
|
|
|
+ Optional, max 500 characters, current: {{ formData.remark.length }}/500
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Save button -->
|
|
|
+ <div class="flex justify-end gap-3 pt-4">
|
|
|
+ <button v-if="!isFirstFill" type="button"
|
|
|
+ @click="() => { isEditing = false; maintenancePassed = false; maintenanceCode = ''; resetForm(linkInfo) }"
|
|
|
+ class="rounded-2xl border border-slate-300 bg-white px-6 py-3.5 text-base font-semibold text-slate-700 transition-all duration-200 hover:bg-slate-50"
|
|
|
+ :disabled="loading.saving">
|
|
|
+ Cancel
|
|
|
+ </button>
|
|
|
+ <button type="submit"
|
|
|
+ class="rounded-2xl bg-gradient-to-r from-cyan-400 to-blue-400 px-8 py-3.5 text-base font-semibold text-slate-900 shadow-lg shadow-cyan-500/30 transition-all duration-200 hover:scale-105 hover:shadow-xl hover:shadow-cyan-500/40 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
|
|
+ :disabled="loading.saving">
|
|
|
+ <i v-if="loading.saving" class="pi pi-spin pi-spinner mr-2" />
|
|
|
+ <i v-else class="pi pi-check mr-2" />
|
|
|
+ {{ loading.saving ? 'Saving...' : hasLinkInfo ? 'Save changes' : 'Submit link' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 维护码验证对话框 -->
|
|
|
+ <Dialog v-model:visible="showMaintenanceDialog" modal :closable="!isFirstFill" :closeOnEscape="!isFirstFill"
|
|
|
+ :dismissableMask="!isFirstFill" :style="{ width: '90vw', maxWidth: '450px' }" :draggable="false">
|
|
|
+ <template #header>
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <div class="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-100">
|
|
|
+ <i class="pi pi-lock text-lg text-cyan-600" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <h3 class="text-lg font-semibold text-slate-900">
|
|
|
+ {{ isFirstFill ? 'Verification required for first use' : 'Verification required to edit' }}
|
|
|
+ </h3>
|
|
|
+ <p class="text-sm text-slate-500">
|
|
|
+ {{ isFirstFill ? 'Enter the maintenance code that came with the QR tag.' : 'Enter the maintenance code to unlock editing.' }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div class="space-y-4 py-4">
|
|
|
+ <div>
|
|
|
+ <label class="mb-2 block text-sm font-medium text-slate-700">Maintenance code</label>
|
|
|
+ <input v-model="maintenanceCode" type="text" maxlength="8"
|
|
|
+ class="w-full rounded-xl border border-slate-300 px-4 py-3 text-slate-900 placeholder:text-slate-400 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
|
|
+ placeholder="Enter the maintenance code" @keyup.enter="handleVerifyMaintenance" autofocus />
|
|
|
+ </div>
|
|
|
+ <div v-if="isFirstFill" class="rounded-xl border border-emerald-200 bg-emerald-50 p-3">
|
|
|
+ <div class="flex items-start gap-2">
|
|
|
+ <i class="pi pi-info-circle text-sm text-emerald-600" />
|
|
|
+ <p class="text-xs text-emerald-700">
|
|
|
+ This is the first record for this QR code. Verify to begin entering details.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <div class="flex justify-end gap-3">
|
|
|
+ <button v-if="!isFirstFill" type="button"
|
|
|
+ class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
|
|
+ @click="showMaintenanceDialog = false">
|
|
|
+ Cancel
|
|
|
+ </button>
|
|
|
+ <button type="button"
|
|
|
+ class="rounded-xl bg-cyan-600 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-cyan-700 disabled:opacity-50"
|
|
|
+ :disabled="loading.verifying || !maintenanceCode" @click="handleVerifyMaintenance">
|
|
|
+ {{ loading.verifying ? 'Verifying...' : 'Verify' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </Dialog>
|
|
|
+
|
|
|
+ <!-- Scan records maintenance code dialog -->
|
|
|
+ <Dialog v-model:visible="showScanRecordsMaintenanceDialog" modal header="Verification required"
|
|
|
+ :style="{ width: '90vw', maxWidth: '400px' }" class="p-fluid">
|
|
|
+ <div class="space-y-4">
|
|
|
+ <p class="text-sm text-slate-600">Enter the maintenance code to view scan records.</p>
|
|
|
+ <div class="space-y-2">
|
|
|
+ <label class="block text-sm font-medium text-slate-700">Maintenance code</label>
|
|
|
+ <input v-model="scanRecordsMaintenanceCode" type="text" maxlength="8"
|
|
|
+ class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-900 focus:border-cyan-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/30"
|
|
|
+ placeholder="Enter the maintenance code" @keyup.enter="handleVerifyScanRecordsMaintenance" />
|
|
|
+ </div>
|
|
|
+ <div class="flex justify-end gap-3 pt-2">
|
|
|
+ <button type="button"
|
|
|
+ class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
|
|
+ @click="showScanRecordsMaintenanceDialog = false">
|
|
|
+ Cancel
|
|
|
+ </button>
|
|
|
+ <button type="button"
|
|
|
+ class="rounded-xl bg-cyan-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-cyan-600 disabled:opacity-50"
|
|
|
+ :disabled="loadingScanRecords || !scanRecordsMaintenanceCode"
|
|
|
+ @click="handleVerifyScanRecordsMaintenance">
|
|
|
+ <i v-if="loadingScanRecords" class="pi pi-spin pi-spinner mr-2" />
|
|
|
+ <i v-else class="pi pi-check mr-2" />
|
|
|
+ {{ loadingScanRecords ? 'Verifying...' : 'Verify' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Dialog>
|
|
|
+
|
|
|
+ <!-- Scan records dialog -->
|
|
|
+ <Dialog v-model:visible="showScanRecordsDialog" modal header="Scan Records"
|
|
|
+ :style="{ width: '90vw', maxWidth: '600px' }" class="p-fluid">
|
|
|
+ <div v-if="loadingScanRecords" class="py-8 text-center text-slate-600">
|
|
|
+ <i class="pi pi-spin pi-spinner text-2xl" />
|
|
|
+ <p class="mt-2">Loading...</p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="scanRecords">
|
|
|
+ <div v-if="scanRecords.records && scanRecords.records.length > 0"
|
|
|
+ class="space-y-3 max-h-[60vh] overflow-y-auto">
|
|
|
+ <div v-for="record in scanRecords.records" :key="record.id"
|
|
|
+ class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow">
|
|
|
+ <div class="flex items-start justify-between gap-4">
|
|
|
+ <div class="flex-1 space-y-2">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <i class="pi pi-clock text-sm text-slate-500" />
|
|
|
+ <span class="text-sm font-medium text-slate-700">Scan Time</span>
|
|
|
+ <span class="text-sm text-slate-600">{{ formatDateTime(record.scanTime) }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="record.address" class="flex items-start gap-2">
|
|
|
+ <i class="pi pi-map-marker text-sm text-slate-500 mt-0.5" />
|
|
|
+ <div class="flex-1">
|
|
|
+ <span class="text-sm font-medium text-slate-700">Address</span>
|
|
|
+ <p class="text-sm text-slate-600 break-words">{{ record.address }}</p>
|
|
|
+ </div>
|
|
|
+ <button v-if="record.latitude && record.longitude" type="button"
|
|
|
+ class="ml-2 flex-shrink-0 rounded-lg border border-cyan-200 bg-cyan-50 px-2 py-1 text-xs text-cyan-700 hover:bg-cyan-100 transition"
|
|
|
+ @click="openRecordLocation(record)">
|
|
|
+ <i class="pi pi-external-link mr-1" />
|
|
|
+ Map
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="record.latitude && record.longitude" class="flex items-center gap-2">
|
|
|
+ <i class="pi pi-globe text-sm text-slate-500" />
|
|
|
+ <span class="text-sm text-slate-600">
|
|
|
+ Coordinates: {{ record.latitude }}, {{ record.longitude }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="record.ipAddress" class="flex items-center gap-2">
|
|
|
+ <i class="pi pi-desktop text-sm text-slate-500" />
|
|
|
+ <span class="text-sm text-slate-600">IP Address: {{ record.ipAddress }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="py-8 text-center text-slate-500">
|
|
|
+ <i class="pi pi-inbox text-3xl mb-2" />
|
|
|
+ <p>No scan records</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.jump-page {
|
|
|
+ font-family: system-ui, -apple-system, sans-serif;
|
|
|
+ background-image: radial-gradient(circle at 20% 20%, rgba(14, 165, 233, 0.12), transparent 45%),
|
|
|
+ radial-gradient(circle at 80% 0%, rgba(129, 140, 248, 0.12), transparent 50%),
|
|
|
+ linear-gradient(135deg, #020617, #030712);
|
|
|
+}
|
|
|
+</style>
|