Переглянути джерело

新增跳转链接功能,支持通过二维码访问链接信息,并在路由中添加相应的跳转路径。同时,更新了表单以包含跳转链接和备注字段,优化了相关的验证逻辑和用户界面展示。

wuyi 1 місяць тому
батько
коміт
cda111089b
4 змінених файлів з 972 додано та 42 видалено
  1. 12 0
      src/router/index.js
  2. 2 2
      src/services/api.js
  3. 746 0
      src/views/JumpView.vue
  4. 212 40
      src/views/ScanView.vue

+ 12 - 0
src/router/index.js

@@ -1,5 +1,6 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import { createRouter, createWebHistory } from 'vue-router'
 import ScanView from '@/views/ScanView.vue'
 import ScanView from '@/views/ScanView.vue'
+import JumpView from '@/views/JumpView.vue'
 
 
 const router = createRouter({
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -9,6 +10,17 @@ const router = createRouter({
       name: 'home',
       name: 'home',
       component: ScanView
       component: ScanView
     },
     },
+    {
+      path: '/jump',
+      name: 'jump',
+      component: JumpView
+    },
+    {
+      path: '/jump/:qrCode',
+      name: 'jumpWithCode',
+      component: JumpView,
+      props: true
+    },
     {
     {
       path: '/:qrCode',
       path: '/:qrCode',
       name: 'scan',
       name: 'scan',

+ 2 - 2
src/services/api.js

@@ -73,8 +73,8 @@ export const updateGoodsInfoApi = async (goodsPayload) => {
   return response.data
   return response.data
 }
 }
 
 
-export const createScanRecordApi = async (recordPayload) => {
-  const response = await api.post('/scan/create', recordPayload)
+export const updateLinkInfoApi = async (linkPayload) => {
+  const response = await api.put('/link/update', linkPayload)
   return response.data
   return response.data
 }
 }
 
 

+ 746 - 0
src/views/JumpView.vue

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

+ 212 - 40
src/views/ScanView.vue

@@ -9,6 +9,7 @@ import {
   updatePersonProfileApi,
   updatePersonProfileApi,
   updatePetProfileApi,
   updatePetProfileApi,
   updateGoodsInfoApi,
   updateGoodsInfoApi,
+  updateLinkInfoApi,
   verifyMaintenanceCodeApi,
   verifyMaintenanceCodeApi,
   verifyMaintenanceCodeInfoApi,
   verifyMaintenanceCodeInfoApi,
   uploadFile,
   uploadFile,
@@ -67,7 +68,8 @@ const FORM_KEY_MAP = Object.freeze({
     'isVisible'
     'isVisible'
   ],
   ],
   pet: ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'location', 'remark', 'isVisible'],
   pet: ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'location', 'remark', 'isVisible'],
-  goods: ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'location', 'remark', 'isVisible']
+  goods: ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'location', 'remark', 'isVisible'],
+  link: ['jumpUrl', 'linkRemark', 'isVisible']
 })
 })
 
 
 const DEFAULT_FORM_STATE = Object.freeze({
 const DEFAULT_FORM_STATE = Object.freeze({
@@ -84,7 +86,9 @@ const DEFAULT_FORM_STATE = Object.freeze({
   contactPhone: '',
   contactPhone: '',
   contactEmail: '',
   contactEmail: '',
   location: '',
   location: '',
-  isVisible: true
+  isVisible: true,
+  jumpUrl: '',
+  linkRemark: ''
 })
 })
 
 
 const createDefaultFormState = () => ({
 const createDefaultFormState = () => ({
@@ -97,7 +101,8 @@ const formData = ref(createDefaultFormState())
 const profileApiMap = Object.freeze({
 const profileApiMap = Object.freeze({
   person: updatePersonProfileApi,
   person: updatePersonProfileApi,
   pet: updatePetProfileApi,
   pet: updatePetProfileApi,
-  goods: updateGoodsInfoApi
+  goods: updateGoodsInfoApi,
+  link: updateLinkInfoApi
 })
 })
 
 
 const MAX_PHOTO_SIZE = 15 * 1024 * 1024
 const MAX_PHOTO_SIZE = 15 * 1024 * 1024
@@ -111,19 +116,22 @@ const qrType = computed(() => qrDetail.value?.qrType || 'person')
 const isPerson = computed(() => qrType.value === 'person')
 const isPerson = computed(() => qrType.value === 'person')
 const isPet = computed(() => qrType.value === 'pet')
 const isPet = computed(() => qrType.value === 'pet')
 const isGoods = computed(() => qrType.value === 'goods')
 const isGoods = computed(() => qrType.value === 'goods')
+const isLink = computed(() => qrType.value === 'link')
 const hasProfile = computed(() => Boolean(profile.value))
 const hasProfile = computed(() => Boolean(profile.value))
 const isFirstFill = computed(() => {
 const isFirstFill = computed(() => {
   // 优先判断 isVisible,如果不可见则不认为是第一次使用
   // 优先判断 isVisible,如果不可见则不认为是第一次使用
   if (qrDetail.value?.isVisible === false) {
   if (qrDetail.value?.isVisible === false) {
     return false
     return false
   }
   }
-  return Boolean(qrDetail.value) && !hasProfile.value
+  // 如果 isActivated 为 false 或 undefined,则认为是第一次填写
+  return Boolean(qrDetail.value) && !qrDetail.value?.isActivated
 })
 })
 const heroTitle = computed(() => {
 const heroTitle = computed(() => {
   if (!qrDetail.value) return 'Contact Card'
   if (!qrDetail.value) return 'Contact Card'
   if (isPerson.value) return 'Person Card'
   if (isPerson.value) return 'Person Card'
   if (isPet.value) return 'Pet Card'
   if (isPet.value) return 'Pet Card'
   if (isGoods.value) return 'Item Card'
   if (isGoods.value) return 'Item Card'
+  if (isLink.value) return 'Link Card'
   return 'Contact Card'
   return 'Contact Card'
 })
 })
 
 
@@ -144,43 +152,50 @@ const parseError = (error) => {
 const resetForm = (source = null) => {
 const resetForm = (source = null) => {
   const nextState = createDefaultFormState()
   const nextState = createDefaultFormState()
   if (source) {
   if (source) {
-    getActiveFormKeys().forEach((key) => {
-      if (Object.prototype.hasOwnProperty.call(source, key)) {
-        // 对于布尔值,直接使用值(包括 false)
-        if (key === 'isVisible') {
-          nextState[key] = source[key] !== undefined ? source[key] : true
-        } else if (key === 'remark') {
-          // 处理 remark 字段:如果是字符串,尝试解析为 JSON 对象
-          const value = source[key]
-          if (typeof value === 'string' && value.trim()) {
-            try {
-              const parsed = JSON.parse(value)
-              if (typeof parsed === 'object' && parsed !== null) {
-                nextState[key] = parsed
+    // 对于 link 类型,特殊处理
+    if (isLink.value) {
+      nextState.jumpUrl = source.jumpUrl ?? ''
+      nextState.linkRemark = source.remark ?? ''
+      nextState.isVisible = source.isVisible !== undefined ? source.isVisible : true
+    } else {
+      getActiveFormKeys().forEach((key) => {
+        if (Object.prototype.hasOwnProperty.call(source, key)) {
+          // 对于布尔值,直接使用值(包括 false)
+          if (key === 'isVisible') {
+            nextState[key] = source[key] !== undefined ? source[key] : true
+          } else if (key === 'remark') {
+            // 处理 remark 字段:如果是字符串,尝试解析为 JSON 对象
+            const value = source[key]
+            if (typeof value === 'string' && value.trim()) {
+              try {
+                const parsed = JSON.parse(value)
+                if (typeof parsed === 'object' && parsed !== null) {
+                  nextState[key] = parsed
+                } else {
+                  // 如果解析结果不是对象,将其作为第一个备注
+                  nextState[key] = { '1': value }
+                }
+              } catch (e) {
+                // 如果解析失败,将字符串作为第一个备注
+                nextState[key] = value ? { '1': value } : { '1': '' }
+              }
+            } else if (typeof value === 'object' && value !== null) {
+              // 确保对象至少有一个字段
+              const keys = Object.keys(value)
+              if (keys.length === 0) {
+                nextState[key] = { '1': '' }
               } else {
               } else {
-                // 如果解析结果不是对象,将其作为第一个备注
-                nextState[key] = { '1': value }
+                nextState[key] = value
               }
               }
-            } catch (e) {
-              // 如果解析失败,将字符串作为第一个备注
-              nextState[key] = value ? { '1': value } : { '1': '' }
-            }
-          } else if (typeof value === 'object' && value !== null) {
-            // 确保对象至少有一个字段
-            const keys = Object.keys(value)
-            if (keys.length === 0) {
-              nextState[key] = { '1': '' }
             } else {
             } else {
-              nextState[key] = value
+              nextState[key] = { '1': '' }
             }
             }
           } else {
           } else {
-            nextState[key] = { '1': '' }
+            nextState[key] = source[key] ?? DEFAULT_FORM_STATE[key] ?? ''
           }
           }
-        } else {
-          nextState[key] = source[key] ?? DEFAULT_FORM_STATE[key] ?? ''
         }
         }
-      }
-    })
+      })
+    }
   }
   }
   // 如果 source 中没有 isVisible,则从 qrDetail 中获取
   // 如果 source 中没有 isVisible,则从 qrDetail 中获取
   if (!source || !Object.prototype.hasOwnProperty.call(source, 'isVisible')) {
   if (!source || !Object.prototype.hasOwnProperty.call(source, 'isVisible')) {
@@ -198,6 +213,11 @@ const setDocumentTitle = () => {
     document.title = 'Emergency QR'
     document.title = 'Emergency QR'
     return
     return
   }
   }
+  if (isLink.value) {
+    const linkUrl = profile.value?.jumpUrl || 'Link Information'
+    document.title = `${linkUrl} | Link QR`
+    return
+  }
   const defaultName = DEFAULT_NAME_BY_TYPE[qrType.value] || DEFAULT_NAME_BY_TYPE.person
   const defaultName = DEFAULT_NAME_BY_TYPE[qrType.value] || DEFAULT_NAME_BY_TYPE.person
   const name = profile.value?.name || defaultName
   const name = profile.value?.name || defaultName
   document.title = `${name} | Emergency QR`
   document.title = `${name} | Emergency QR`
@@ -211,6 +231,13 @@ const fetchQrDetails = async () => {
   try {
   try {
     const data = await fetchQrInfoApi(qrCode.value)
     const data = await fetchQrInfoApi(qrCode.value)
     qrDetail.value = data
     qrDetail.value = data
+    
+    // 如果是 link 类型且已激活且有 jumpUrl,直接跳转
+    if (data.qrType === 'link' && data.isActivated && data.info?.jumpUrl) {
+      window.location.href = data.info.jumpUrl
+      return
+    }
+    
     if (data.isVisible === false) {
     if (data.isVisible === false) {
       infoStatus.state = 'notVisible'
       infoStatus.state = 'notVisible'
       infoStatus.message = 'QR code information is not visible'
       infoStatus.message = 'QR code information is not visible'
@@ -302,10 +329,36 @@ const handleVerifyMaintenanceForView = async () => {
   }
   }
 }
 }
 
 
+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
+  }
+}
+
 const buildProfilePayload = () => {
 const buildProfilePayload = () => {
   const payload = {
   const payload = {
     qrCode: qrCode.value
     qrCode: qrCode.value
   }
   }
+  
+  // 对于 link 类型,特殊处理
+  if (isLink.value) {
+    payload.isVisible = formData.value.isVisible !== undefined ? formData.value.isVisible : true
+    if (formData.value.jumpUrl && formData.value.jumpUrl.trim()) {
+      payload.jumpUrl = formData.value.jumpUrl.trim()
+    }
+    if (formData.value.linkRemark && formData.value.linkRemark.trim()) {
+      payload.remark = formData.value.linkRemark.trim()
+    }
+    if (maintenanceCode.value) {
+      payload.maintenanceCode = maintenanceCode.value
+    }
+    return payload
+  }
+  
   getActiveFormKeys().forEach((key) => {
   getActiveFormKeys().forEach((key) => {
     if (key === 'isVisible') {
     if (key === 'isVisible') {
       payload[key] = formData.value[key] !== undefined ? formData.value[key] : true
       payload[key] = formData.value[key] !== undefined ? formData.value[key] : true
@@ -329,12 +382,36 @@ const buildProfilePayload = () => {
 
 
 const handleSaveProfile = async () => {
 const handleSaveProfile = async () => {
   if (!qrCode.value) return
   if (!qrCode.value) return
+  
+  // 对于 link 类型,验证 URL
+  if (isLink.value) {
+    if (formData.value.jumpUrl && formData.value.jumpUrl.trim()) {
+      if (formData.value.jumpUrl.length > 2000) {
+        toast.add({ severity: 'warn', summary: 'Notice', detail: 'Jump URL cannot exceed 2000 characters.', life: 2400 })
+        return
+      }
+      if (!isValidUrl(formData.value.jumpUrl)) {
+        toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a valid URL (must start with http:// or https://).', life: 2400 })
+        return
+      }
+    }
+    if (formData.value.linkRemark && formData.value.linkRemark.length > 500) {
+      toast.add({ severity: 'warn', summary: 'Notice', detail: 'Remark cannot exceed 500 characters.', life: 2400 })
+      return
+    }
+  }
+  
   loading.saving = true
   loading.saving = true
   try {
   try {
     const payload = buildProfilePayload()
     const payload = buildProfilePayload()
     const updater = profileApiMap[qrType.value] || profileApiMap.person
     const updater = profileApiMap[qrType.value] || profileApiMap.person
-    await updater(payload)
-    toast.add({ severity: 'success', summary: 'Saved', detail: 'Profile has been updated.', life: 3000 })
+    const response = await updater(payload)
+    toast.add({ 
+      severity: 'success', 
+      summary: 'Saved', 
+      detail: response?.message || (isLink.value ? 'Link information has been updated.' : 'Profile has been updated.'), 
+      life: 3000 
+    })
     await fetchQrDetails()
     await fetchQrDetails()
     isEditing.value = false
     isEditing.value = false
     maintenancePassed.value = false
     maintenancePassed.value = false
@@ -784,7 +861,73 @@ onMounted(() => {
 
 
           <!-- Only render details after maintenance verification -->
           <!-- Only render details after maintenance verification -->
           <div v-if="infoStatus.state === 'ready' && qrDetail && (maintenancePassed || hasProfile)" class="space-y-6">
           <div v-if="infoStatus.state === 'ready' && qrDetail && (maintenancePassed || hasProfile)" class="space-y-6">
-            <div v-if="hasProfile && !isEditing"
+            <!-- Link type display -->
+            <div v-if="isLink && hasProfile && !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">
+                <!-- Jump URL card -->
+                <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="profile.jumpUrl"
+                      class="cursor-pointer text-lg font-semibold text-slate-800 break-all mb-4"
+                      title="Click to copy link" @click="copyInfo(profile.jumpUrl, 'Link')">
+                      {{ profile.jumpUrl }}
+                    </p>
+                    <p v-else class="text-sm text-slate-500 mb-4">Not set</p>
+                    <button v-if="profile.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="() => window.open(profile.jumpUrl, '_blank')">
+                      <i class="pi pi-external-link" /> Open link
+                    </button>
+                  </div>
+                </div>
+
+                <!-- Remark card -->
+                <div v-if="profile.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">
+                      {{ profile.remark }}
+                    </p>
+                  </div>
+                </div>
+
+                <!-- Action buttons -->
+                <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>
+
+            <!-- Other types display -->
+            <div v-else-if="hasProfile && !isEditing && !isLink"
               class="rounded-3xl border border-white/10 bg-white text-slate-900 shadow-2xl shadow-cyan-500/10 relative">
               class="rounded-3xl border border-white/10 bg-white text-slate-900 shadow-2xl shadow-cyan-500/10 relative">
               <template v-if="hasProfile">
               <template v-if="hasProfile">
                 <div class="space-y-6 p-6">
                 <div class="space-y-6 p-6">
@@ -941,9 +1084,9 @@ onMounted(() => {
           class="rounded-3xl border border-white/10 bg-white p-6 text-slate-900 shadow-2xl shadow-cyan-500/10">
           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 class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
             <div>
             <div>
-              <p class="text-xs uppercase tracking-[0.3em] text-slate-400">Profile setup</p>
+              <p class="text-xs uppercase tracking-[0.3em] text-slate-400">{{ isLink ? 'Link setup' : 'Profile setup' }}</p>
               <h2 class="mt-1 text-2xl font-semibold text-slate-900">
               <h2 class="mt-1 text-2xl font-semibold text-slate-900">
-                {{ hasProfile ? 'Update profile' : 'First-time setup' }}
+                {{ isLink ? (hasProfile ? 'Update link' : 'First-time setup') : (hasProfile ? 'Update profile' : 'First-time setup') }}
               </h2>
               </h2>
             </div>
             </div>
             <p class="text-sm text-slate-500">
             <p class="text-sm text-slate-500">
@@ -977,6 +1120,34 @@ onMounted(() => {
           </div>
           </div>
 
 
           <form class="mt-6 space-y-6" @submit.prevent="handleSaveProfile">
           <form class="mt-6 space-y-6" @submit.prevent="handleSaveProfile">
+            <!-- Link type form -->
+            <template v-if="isLink">
+              <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>
+
+              <div class="space-y-2">
+                <label class="block text-sm font-medium text-slate-700">
+                  Remark
+                </label>
+                <textarea v-model="formData.linkRemark" 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.linkRemark.length }}/500
+                </p>
+              </div>
+            </template>
+
+            <!-- Other types form -->
+            <template v-else>
             <div class="space-y-4">
             <div class="space-y-4">
               <div>
               <div>
                 <p class="text-sm font-medium text-slate-700">Display photo / item image</p>
                 <p class="text-sm font-medium text-slate-700">Display photo / item image</p>
@@ -1164,6 +1335,7 @@ onMounted(() => {
                 </label>
                 </label>
               </div>
               </div>
             </div>
             </div>
+            </template>
 
 
             <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
             <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
               <p class="text-sm text-slate-500">
               <p class="text-sm text-slate-500">
@@ -1172,7 +1344,7 @@ onMounted(() => {
               <button type="submit"
               <button type="submit"
                 class="rounded-full bg-slate-900 px-8 py-3 text-sm font-semibold text-white hover:bg-slate-800"
                 class="rounded-full bg-slate-900 px-8 py-3 text-sm font-semibold text-white hover:bg-slate-800"
                 :disabled="loading.saving">
                 :disabled="loading.saving">
-                {{ loading.saving ? 'Saving...' : hasProfile ? 'Save changes' : 'Submit profile' }}
+                {{ loading.saving ? 'Saving...' : (isLink ? (hasProfile ? 'Save changes' : 'Submit link') : (hasProfile ? 'Save changes' : 'Submit profile')) }}
               </button>
               </button>
             </div>
             </div>
           </form>
           </form>