瀏覽代碼

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

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

+ 2 - 2
src/services/api.js

@@ -73,8 +73,8 @@ export const updateGoodsInfoApi = async (goodsPayload) => {
   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
 }
 

+ 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,
   updatePetProfileApi,
   updateGoodsInfoApi,
+  updateLinkInfoApi,
   verifyMaintenanceCodeApi,
   verifyMaintenanceCodeInfoApi,
   uploadFile,
@@ -67,7 +68,8 @@ const FORM_KEY_MAP = Object.freeze({
     '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({
@@ -84,7 +86,9 @@ const DEFAULT_FORM_STATE = Object.freeze({
   contactPhone: '',
   contactEmail: '',
   location: '',
-  isVisible: true
+  isVisible: true,
+  jumpUrl: '',
+  linkRemark: ''
 })
 
 const createDefaultFormState = () => ({
@@ -97,7 +101,8 @@ const formData = ref(createDefaultFormState())
 const profileApiMap = Object.freeze({
   person: updatePersonProfileApi,
   pet: updatePetProfileApi,
-  goods: updateGoodsInfoApi
+  goods: updateGoodsInfoApi,
+  link: updateLinkInfoApi
 })
 
 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 isPet = computed(() => qrType.value === 'pet')
 const isGoods = computed(() => qrType.value === 'goods')
+const isLink = computed(() => qrType.value === 'link')
 const hasProfile = computed(() => Boolean(profile.value))
 const isFirstFill = computed(() => {
   // 优先判断 isVisible,如果不可见则不认为是第一次使用
   if (qrDetail.value?.isVisible === false) {
     return false
   }
-  return Boolean(qrDetail.value) && !hasProfile.value
+  // 如果 isActivated 为 false 或 undefined,则认为是第一次填写
+  return Boolean(qrDetail.value) && !qrDetail.value?.isActivated
 })
 const heroTitle = computed(() => {
   if (!qrDetail.value) return 'Contact Card'
   if (isPerson.value) return 'Person Card'
   if (isPet.value) return 'Pet Card'
   if (isGoods.value) return 'Item Card'
+  if (isLink.value) return 'Link Card'
   return 'Contact Card'
 })
 
@@ -144,43 +152,50 @@ const parseError = (error) => {
 const resetForm = (source = null) => {
   const nextState = createDefaultFormState()
   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 {
-                // 如果解析结果不是对象,将其作为第一个备注
-                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 {
-              nextState[key] = value
+              nextState[key] = { '1': '' }
             }
           } else {
-            nextState[key] = { '1': '' }
+            nextState[key] = source[key] ?? DEFAULT_FORM_STATE[key] ?? ''
           }
-        } else {
-          nextState[key] = source[key] ?? DEFAULT_FORM_STATE[key] ?? ''
         }
-      }
-    })
+      })
+    }
   }
   // 如果 source 中没有 isVisible,则从 qrDetail 中获取
   if (!source || !Object.prototype.hasOwnProperty.call(source, 'isVisible')) {
@@ -198,6 +213,11 @@ const setDocumentTitle = () => {
     document.title = 'Emergency QR'
     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 name = profile.value?.name || defaultName
   document.title = `${name} | Emergency QR`
@@ -211,6 +231,13 @@ const fetchQrDetails = async () => {
   try {
     const data = await fetchQrInfoApi(qrCode.value)
     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) {
       infoStatus.state = 'notVisible'
       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 payload = {
     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) => {
     if (key === 'isVisible') {
       payload[key] = formData.value[key] !== undefined ? formData.value[key] : true
@@ -329,12 +382,36 @@ const buildProfilePayload = () => {
 
 const handleSaveProfile = async () => {
   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
   try {
     const payload = buildProfilePayload()
     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()
     isEditing.value = false
     maintenancePassed.value = false
@@ -784,7 +861,73 @@ onMounted(() => {
 
           <!-- Only render details after maintenance verification -->
           <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">
               <template v-if="hasProfile">
                 <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">
           <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">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">
-                {{ hasProfile ? 'Update profile' : 'First-time setup' }}
+                {{ isLink ? (hasProfile ? 'Update link' : 'First-time setup') : (hasProfile ? 'Update profile' : 'First-time setup') }}
               </h2>
             </div>
             <p class="text-sm text-slate-500">
@@ -977,6 +1120,34 @@ onMounted(() => {
           </div>
 
           <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>
                 <p class="text-sm font-medium text-slate-700">Display photo / item image</p>
@@ -1164,6 +1335,7 @@ onMounted(() => {
                 </label>
               </div>
             </div>
+            </template>
 
             <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
               <p class="text-sm text-slate-500">
@@ -1172,7 +1344,7 @@ onMounted(() => {
               <button type="submit"
                 class="rounded-full bg-slate-900 px-8 py-3 text-sm font-semibold text-white hover:bg-slate-800"
                 :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>
             </div>
           </form>