wuyi 1 mesiac pred
rodič
commit
cd6ccfa7c0
4 zmenil súbory, kde vykonal 887 pridanie a 113 odobranie
  1. 1 0
      package.json
  2. 653 0
      src/components/LocationPicker.vue
  3. 228 113
      src/views/ScanView.vue
  4. 5 0
      yarn.lock

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
     "axios": "^1.8.4",
     "chart.js": "^4.4.8",
     "decimal.js": "^10.5.0",
+    "leaflet": "^1.9.4",
     "less": "^4.2.2",
     "pinia": "^3.0.1",
     "primeflex": "^4.0.0",

+ 653 - 0
src/components/LocationPicker.vue

@@ -0,0 +1,653 @@
+<script setup>
+import { ref, watch, nextTick, onBeforeUnmount, computed } from 'vue'
+import Dialog from 'primevue/dialog'
+
+// Leaflet 将使用懒加载
+let L = null
+let searchAbortController = null
+
+const props = defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false
+  },
+  initialLocation: {
+    type: String,
+    default: ''
+  },
+  closable: {
+    type: Boolean,
+    default: true
+  },
+  readonly: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits(['update:modelValue', 'save'])
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (value) => emit('update:modelValue', value)
+})
+
+const mapContainer = ref(null)
+const mapInstance = ref(null)
+const mapMarker = ref(null)
+const selectedLocation = ref({ lat: null, lng: null, address: '' })
+const loadingMap = ref(false)
+const mapLoaded = ref(false)
+const searchQuery = ref('')
+const searchResults = ref([])
+const searchLoading = ref(false)
+const searchError = ref('')
+
+// 懒加载 Leaflet
+const loadLeaflet = async () => {
+  if (mapLoaded.value && L) return L
+
+  loadingMap.value = true
+  try {
+    // 动态导入 Leaflet 和 CSS
+    const [leafletModule] = await Promise.all([
+      import('leaflet'),
+      import('leaflet/dist/leaflet.css')
+    ])
+
+    L = leafletModule.default
+
+    // 修复 Leaflet 默认图标路径问题
+    delete L.Icon.Default.prototype._getIconUrl
+    L.Icon.Default.mergeOptions({
+      iconRetinaUrl: new URL('leaflet/dist/images/marker-icon-2x.png', import.meta.url).href,
+      iconUrl: new URL('leaflet/dist/images/marker-icon.png', import.meta.url).href,
+      shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href
+    })
+
+    mapLoaded.value = true
+    return L
+  } catch (error) {
+    loadingMap.value = false
+    throw error
+  } finally {
+    loadingMap.value = false
+  }
+}
+
+const initMap = async () => {
+  if (!mapContainer.value) return
+
+  // 懒加载 Leaflet
+  try {
+    await loadLeaflet()
+  } catch (error) {
+    return
+  }
+
+  // 如果地图实例已存在,先移除
+  if (mapInstance.value) {
+    try {
+      mapInstance.value.remove()
+    } catch (e) {
+      // 忽略移除错误
+    }
+    mapInstance.value = null
+    mapMarker.value = null
+  }
+
+  await nextTick()
+
+  // 确保容器可见
+  if (!mapContainer.value || mapContainer.value.offsetParent === null) {
+    return
+  }
+
+  // 默认位置:纽约市
+  const defaultLat = 40.7128
+  const defaultLng = -74.0060
+  let initialLat = defaultLat
+  let initialLng = defaultLng
+
+  // 如果已有选中的位置,使用它
+  if (selectedLocation.value.lat && selectedLocation.value.lng) {
+    initialLat = selectedLocation.value.lat
+    initialLng = selectedLocation.value.lng
+  } else if (!props.readonly) {
+    // 只在非只读模式下尝试获取用户位置
+    const isSecureContext = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1'
+
+    if (isSecureContext && navigator.geolocation) {
+      try {
+        const position = await new Promise((resolve, reject) => {
+          navigator.geolocation.getCurrentPosition(
+            resolve,
+            (error) => {
+              reject(error)
+            },
+            {
+              enableHighAccuracy: true,
+              timeout: 5000,
+              maximumAge: 0
+            }
+          )
+        })
+        initialLat = position.coords.latitude
+        initialLng = position.coords.longitude
+      } catch (error) {
+        // 静默失败,使用默认位置
+      }
+    }
+  }
+
+  // 初始化地图
+  mapInstance.value = L.map(mapContainer.value, {
+    center: [initialLat, initialLng],
+    zoom: 13,
+    zoomControl: true
+  })
+
+  // 添加地图图层
+  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+    attribution: '© OpenStreetMap contributors',
+    maxZoom: 19
+  }).addTo(mapInstance.value)
+
+  // 添加初始标记
+  mapMarker.value = L.marker([initialLat, initialLng], {
+    draggable: !props.readonly
+  }).addTo(mapInstance.value)
+
+  selectedLocation.value = { lat: initialLat, lng: initialLng, address: '' }
+
+  // 确保地图正确渲染
+  setTimeout(() => {
+    if (mapInstance.value) {
+      mapInstance.value.invalidateSize()
+      mapInstance.value._onResize()
+    }
+  }, 100)
+
+
+  // 地图点击事件(只读模式下禁用)
+  if (!props.readonly) {
+    mapInstance.value.on('click', (e) => {
+      const { lat, lng } = e.latlng
+      updateMarker(lat, lng)
+    })
+  }
+
+  // 标记拖拽事件
+  mapMarker.value.on('dragend', (e) => {
+    const { lat, lng } = e.target.getLatLng()
+    updateMarker(lat, lng)
+  })
+
+  // 初始位置的反向地理编码
+  reverseGeocode(initialLat, initialLng)
+}
+
+const updateMarker = (lat, lng) => {
+  if (mapMarker.value) {
+    mapMarker.value.setLatLng([lat, lng])
+  }
+  selectedLocation.value = { lat, lng, address: '' }
+  reverseGeocode(lat, lng)
+}
+
+// 精简地址,只保留关键信息
+const simplifyAddress = (data) => {
+  if (!data || !data.address) {
+    return null
+  }
+
+  const addr = data.address
+  const parts = []
+
+  // 街道地址(建筑物名称 + 街道号码 + 街道名称)
+  if (addr.building || addr.house_number || addr.road) {
+    const streetParts = []
+    if (addr.building) streetParts.push(addr.building)
+    if (addr.house_number) streetParts.push(addr.house_number)
+    if (addr.road) streetParts.push(addr.road)
+    if (streetParts.length > 0) {
+      parts.push(streetParts.join(' '))
+    }
+  }
+
+  // 城市/镇
+  if (addr.city) {
+    parts.push(addr.city)
+  } else if (addr.town) {
+    parts.push(addr.town)
+  } else if (addr.village) {
+    parts.push(addr.village)
+  } else if (addr.municipality) {
+    parts.push(addr.municipality)
+  }
+
+  // 州/省
+  if (addr.state) {
+    parts.push(addr.state)
+  }
+
+  // 国家
+  if (addr.country) {
+    parts.push(addr.country)
+  }
+
+  return parts.length > 0 ? parts.join(', ') : null
+}
+
+// 正向地理编码:从地址字符串获取坐标
+const geocode = async (address) => {
+  try {
+    const response = await fetch(
+      `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1&accept-language=en`
+    )
+    const data = await response.json()
+    if (data && data.length > 0) {
+      return {
+        lat: parseFloat(data[0].lat),
+        lng: parseFloat(data[0].lon),
+        address: address
+      }
+    }
+    return null
+  } catch (error) {
+    console.error('Geocoding error:', error)
+    return null
+  }
+}
+
+const reverseGeocode = async (lat, lng) => {
+  try {
+    const response = await fetch(
+      `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1&accept-language=en`
+    )
+    const data = await response.json()
+    if (data) {
+      // 优先使用精简地址
+      const simplified = simplifyAddress(data)
+      if (simplified) {
+        selectedLocation.value.address = simplified
+      } else if (data.display_name) {
+        selectedLocation.value.address = data.display_name
+      } else {
+        selectedLocation.value.address = `${lat.toFixed(6)}, ${lng.toFixed(6)}`
+      }
+    } else {
+      selectedLocation.value.address = `${lat.toFixed(6)}, ${lng.toFixed(6)}`
+    }
+  } catch (error) {
+    selectedLocation.value.address = `${lat.toFixed(6)}, ${lng.toFixed(6)}`
+  }
+}
+
+const ensureMapReady = async () => {
+  if (!mapInstance.value) {
+    await initMap()
+  }
+}
+
+const resetSearchState = (clearQuery = false) => {
+  if (clearQuery) {
+    searchQuery.value = ''
+  }
+  if (searchAbortController) {
+    searchAbortController.abort()
+    searchAbortController = null
+  }
+  searchResults.value = []
+  searchError.value = ''
+  searchLoading.value = false
+}
+
+const destroyMapInstance = () => {
+  if (mapMarker.value) {
+    try {
+      mapMarker.value.off('dragend')
+    } catch (error) {
+      // 忽略标记事件清理错误
+    } finally {
+      mapMarker.value = null
+    }
+  }
+
+  if (mapInstance.value) {
+    try {
+      mapInstance.value.off()
+      mapInstance.value.remove()
+    } catch (error) {
+    } finally {
+      mapInstance.value = null
+    }
+  }
+
+  resetSearchState(true)
+}
+
+const handleSave = () => {
+  if (selectedLocation.value.lat && selectedLocation.value.lng) {
+    const location = selectedLocation.value.address || `${selectedLocation.value.lat}, ${selectedLocation.value.lng}`
+    emit('save', location)
+    // 如果允许关闭,则关闭弹窗;否则保持打开
+    if (props.closable) {
+      visible.value = false
+    }
+  }
+}
+
+const handleClose = () => {
+  // 如果允许关闭,则关闭弹窗
+  if (props.closable) {
+    visible.value = false
+  }
+}
+
+watch(searchQuery, (value) => {
+  if (!value.trim()) {
+    resetSearchState()
+  }
+})
+
+const searchLocations = async () => {
+  const query = searchQuery.value.trim()
+  if (!query) {
+    resetSearchState()
+    searchLoading.value = false
+    return
+  }
+
+  if (searchAbortController) {
+    searchAbortController.abort()
+  }
+
+  searchLoading.value = true
+  searchError.value = ''
+  searchAbortController = new AbortController()
+
+  try {
+    const response = await fetch(
+      `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&addressdetails=1&limit=5&accept-language=en`,
+      {
+        signal: searchAbortController.signal
+      }
+    )
+
+    if (!response.ok) {
+      throw new Error('Search failed')
+    }
+
+    const data = await response.json()
+    searchResults.value = data.map((item) => {
+      // 尝试精简地址
+      const simplified = simplifyAddress(item)
+      return {
+        id: item.osm_id ?? `${item.lat}-${item.lon}`,
+        displayName: simplified || item.display_name,
+        lat: parseFloat(item.lat),
+        lng: parseFloat(item.lon)
+      }
+    })
+
+    if (searchResults.value.length === 0) {
+      searchError.value = 'No matching address found.'
+    } else {
+      searchError.value = ''
+    }
+  } catch (error) {
+    if (error.name === 'AbortError') {
+      return
+    }
+    searchError.value = 'Search failed, please try again later.'
+  } finally {
+    searchLoading.value = false
+    searchAbortController = null
+  }
+}
+
+const handleSearchSelect = async (result) => {
+  searchQuery.value = result.displayName
+  selectedLocation.value = {
+    lat: result.lat,
+    lng: result.lng,
+    address: result.displayName
+  }
+
+  searchResults.value = []
+  searchError.value = ''
+
+  await ensureMapReady()
+
+  if (mapInstance.value) {
+    mapInstance.value.setView([result.lat, result.lng], 15, { animate: true })
+  }
+
+  if (!mapMarker.value) {
+    mapMarker.value = L.marker([result.lat, result.lng], {
+      draggable: !props.readonly
+    }).addTo(mapInstance.value)
+    mapMarker.value.on('dragend', (e) => {
+      const { lat, lng } = e.target.getLatLng()
+      updateMarker(lat, lng)
+    })
+  }
+
+  updateMarker(result.lat, result.lng)
+}
+
+// 监听弹窗显示状态
+watch(
+  () => props.modelValue,
+  async (visible) => {
+    if (visible) {
+      // 如果已有地址,尝试解析坐标(格式:lat, lng 或地址字符串)
+      if (props.initialLocation) {
+        const coordsMatch = props.initialLocation.match(/(-?\d+\.?\d*),\s*(-?\d+\.?\d*)/)
+        if (coordsMatch) {
+          // 是坐标格式
+          selectedLocation.value = {
+            lat: parseFloat(coordsMatch[1]),
+            lng: parseFloat(coordsMatch[2]),
+            address: props.initialLocation
+          }
+        } else {
+          // 是地址字符串,需要进行地理编码获取坐标
+          selectedLocation.value.address = props.initialLocation
+          const geocodeResult = await geocode(props.initialLocation)
+          if (geocodeResult) {
+            selectedLocation.value.lat = geocodeResult.lat
+            selectedLocation.value.lng = geocodeResult.lng
+          }
+        }
+      }
+      // 等待 DOM 更新和 Dialog 动画完成
+      await nextTick()
+      // 使用 setTimeout 确保 Dialog 完全显示后再初始化地图
+      setTimeout(async () => {
+        if (mapContainer.value) {
+          if (!mapInstance.value) {
+            // 首次打开,初始化地图
+            initMap()
+          } else {
+            // 再次打开,检查地图实例是否有效
+            try {
+              await loadLeaflet()
+
+              // 检查地图实例是否真的有效
+              if (mapInstance.value) {
+                const container = mapInstance.value.getContainer()
+                if (!container) {
+                  mapInstance.value = null
+                  mapMarker.value = null
+                  initMap()
+                  return
+                }
+              } else {
+                mapInstance.value = null
+                mapMarker.value = null
+                initMap()
+                return
+              }
+
+              // 确保容器可见后再更新
+              if (mapContainer.value && mapContainer.value.offsetParent !== null) {
+
+                // 检查地图图层是否存在
+                const hasTileLayer = mapInstance.value.eachLayer && Array.from(mapInstance.value._layers || {}).some(layer => layer instanceof L.TileLayer)
+
+                // 如果图层丢失,重新添加
+                if (!hasTileLayer) {
+                  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+                    attribution: '© OpenStreetMap contributors',
+                    maxZoom: 19
+                  }).addTo(mapInstance.value)
+                }
+
+                // 更新地图大小(重要:在容器可见后调用)
+                setTimeout(() => {
+                  if (mapInstance.value) {
+                    mapInstance.value.invalidateSize()
+
+                    // 强制刷新地图
+                    setTimeout(() => {
+                      mapInstance.value.invalidateSize()
+                      mapInstance.value._onResize()
+                    }, 100)
+
+                    // 如果有已保存的位置,更新地图视图
+                    if (selectedLocation.value.lat && selectedLocation.value.lng) {
+                      mapInstance.value.setView([selectedLocation.value.lat, selectedLocation.value.lng], 13, { animate: false })
+                      if (!mapMarker.value) {
+                        // 如果标记不存在,重新创建
+                        mapMarker.value = L.marker([selectedLocation.value.lat, selectedLocation.value.lng], {
+                          draggable: !props.readonly
+                        }).addTo(mapInstance.value)
+                        mapMarker.value.on('dragend', (e) => {
+                          const { lat, lng } = e.target.getLatLng()
+                          updateMarker(lat, lng)
+                        })
+                      } else {
+                        updateMarker(selectedLocation.value.lat, selectedLocation.value.lng)
+                      }
+                    } else {
+                      // 如果没有保存的位置,重新获取用户位置或使用默认位置
+                      const defaultLat = 40.7128
+                      const defaultLng = -74.0060
+                      mapInstance.value.setView([defaultLat, defaultLng], 13, { animate: false })
+                      if (!mapMarker.value) {
+                        mapMarker.value = L.marker([defaultLat, defaultLng], {
+                          draggable: !props.readonly
+                        }).addTo(mapInstance.value)
+                        mapMarker.value.on('dragend', (e) => {
+                          const { lat, lng } = e.target.getLatLng()
+                          updateMarker(lat, lng)
+                        })
+                      } else {
+                        updateMarker(defaultLat, defaultLng)
+                      }
+                    }
+                  }
+                }, 50)
+              } else {
+                // 容器不可见,重新初始化
+                mapInstance.value = null
+                mapMarker.value = null
+                initMap()
+              }
+            } catch (error) {
+              // 如果地图实例无效,重新初始化
+              mapInstance.value = null
+              mapMarker.value = null
+              initMap()
+            }
+          }
+        }
+      }, 150) // 等待 Dialog 动画完成(稍微增加延迟确保完全显示)
+    } else {
+      destroyMapInstance()
+    }
+  }
+)
+
+onBeforeUnmount(() => {
+  destroyMapInstance()
+})
+</script>
+
+<template>
+  <Dialog v-model:visible="visible" modal :closable="closable" :closeOnEscape="closable" :dismissableMask="closable"
+    :style="{ width: '90vw', maxWidth: '800px' }" :draggable="false" @hide="handleClose">
+    <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-map-marker text-lg text-cyan-600" />
+        </div>
+        <div>
+          <h3 class="text-lg font-semibold text-slate-900">{{ readonly ? 'View address' : 'Select address' }}</h3>
+          <p class="text-sm text-slate-500">{{ readonly ? 'Preview the stored location.' : 'Click the map or drag the marker to choose a location.' }}</p>
+        </div>
+      </div>
+    </template>
+
+    <div class="space-y-4 py-4">
+      <div v-if="!readonly" class="space-y-2">
+        <label class="text-sm font-medium text-slate-700">Search places</label>
+        <div class="flex flex-col gap-2 sm:flex-row">
+          <input v-model="searchQuery" type="text"
+            class="flex-1 rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-700 outline-none focus:border-cyan-500 focus:ring-2 focus:ring-cyan-200"
+            placeholder="Type a city, street, or landmark. Press Enter or click Search." @keyup.enter="searchLocations" />
+          <button type="button"
+            class="rounded-xl bg-cyan-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-cyan-700 disabled:cursor-not-allowed disabled:opacity-50"
+            :disabled="searchLoading" @click="searchLocations">
+            {{ searchLoading ? 'Searching...' : 'Search' }}
+          </button>
+        </div>
+        <p v-if="searchError" class="text-xs text-rose-500">{{ searchError }}</p>
+        <div v-if="searchResults.length"
+          class="max-h-48 overflow-y-auto rounded-xl border border-slate-200 bg-white shadow-sm">
+          <button v-for="result in searchResults" :key="result.id" type="button"
+            class="w-full border-b border-slate-100 px-4 py-3 text-left last:border-b-0 hover:bg-slate-50"
+            @click="handleSearchSelect(result)">
+            <p class="text-sm font-medium text-slate-700">{{ result.displayName }}</p>
+            <p class="text-xs text-slate-500">
+              Coordinates: {{ result.lat.toFixed(6) }}, {{ result.lng.toFixed(6) }}
+            </p>
+          </button>
+        </div>
+      </div>
+
+      <div ref="mapContainer" class="w-full h-96 rounded-xl overflow-hidden border border-slate-200 relative">
+        <div v-if="loadingMap" class="absolute inset-0 flex items-center justify-center bg-slate-100 z-10">
+          <div class="text-center">
+            <i class="pi pi-spin pi-spinner text-4xl text-slate-400 mb-2"></i>
+            <p class="text-sm text-slate-500">Loading map...</p>
+          </div>
+        </div>
+      </div>
+      <div v-if="selectedLocation.address" class="rounded-xl border border-slate-200 bg-slate-50 p-3">
+        <p class="text-sm font-medium text-slate-700">{{ readonly ? 'Location details:' : 'Selected address:' }}</p>
+        <p class="mt-1 text-sm text-slate-600">{{ selectedLocation.address }}</p>
+        <p class="mt-1 text-xs text-slate-500">
+          Coordinates: {{ selectedLocation.lat?.toFixed(6) }}, {{ selectedLocation.lng?.toFixed(6) }}
+        </p>
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="flex justify-end gap-3">
+        <button v-if="closable" 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="handleClose">
+          {{ readonly ? 'Close' : 'Cancel' }}
+        </button>
+        <button v-if="!readonly" 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="!selectedLocation.lat || !selectedLocation.lng" @click="handleSave">
+          {{ closable ? 'Save address' : 'Confirm selection' }}
+        </button>
+      </div>
+    </template>
+  </Dialog>
+</template>

+ 228 - 113
src/views/ScanView.vue

@@ -3,6 +3,7 @@ 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 LocationPicker from '@/components/LocationPicker.vue'
 import {
   fetchQrInfoApi,
   updatePersonProfileApi,
@@ -16,7 +17,7 @@ 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)
 
@@ -39,6 +40,8 @@ const maintenancePassed = ref(false)
 const showMaintenancePanel = ref(false)
 const showMaintenanceDialog = ref(false)
 const isEditing = ref(false)
+const showLocationDialog = ref(false)
+const showLocationViewDialog = ref(false)
 
 const personKeys = [
   'photoUrl',
@@ -50,8 +53,8 @@ const personKeys = [
   'emergencyContactPhone',
   'emergencyContactEmail'
 ]
-const petKeys = ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'remark']
-const goodsKeys = ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'remark']
+const petKeys = ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'location', 'remark']
+const goodsKeys = ['photoUrl', 'name', 'contactName', 'contactPhone', 'contactEmail', 'location', 'remark']
 
 const formData = reactive({
   photoUrl: '',
@@ -65,7 +68,8 @@ const formData = reactive({
   remark: '',
   contactName: '',
   contactPhone: '',
-  contactEmail: ''
+  contactEmail: '',
+  location: ''
 })
 
 const qrType = computed(() => qrDetail.value?.qrType || 'person')
@@ -75,22 +79,22 @@ const isGoods = computed(() => qrType.value === 'goods')
 const hasProfile = computed(() => Boolean(profile.value))
 const isFirstFill = computed(() => Boolean(qrDetail.value) && !hasProfile.value)
 const heroTitle = computed(() => {
-  if (!qrDetail.value) return '联系卡'
-  if (isPerson.value) return '人员卡'
-  if (isPet.value) return '宠物卡'
-  if (isGoods.value) return '物品卡'
-  return '联系卡'
+  if (!qrDetail.value) return 'Contact Card'
+  if (isPerson.value) return 'Person Card'
+  if (isPet.value) return 'Pet Card'
+  if (isGoods.value) return 'Item Card'
+  return 'Contact Card'
 })
 
 const parseError = (error) => {
-  if (!error) return '请求失败'
+  if (!error) return 'Request failed'
   if (typeof error === 'string') return error
   if (error.message) return error.message
   if (error.detail) return error.detail
   if (error.response?.data?.message) return error.response.data.message
   if (error.data?.message) return error.data.message
   if (error.message === undefined && error?.error) return error.error
-  return '请求失败'
+  return 'Request failed'
 }
 
 const resetForm = (source) => {
@@ -114,11 +118,11 @@ const resetForm = (source) => {
 
 const setDocumentTitle = () => {
   if (typeof document === 'undefined') return
-  let defaultName = '人员'
-  if (isPet.value) defaultName = '宠物'
-  if (isGoods.value) defaultName = '物品'
+  let defaultName = 'Person'
+  if (isPet.value) defaultName = 'Pet'
+  if (isGoods.value) defaultName = 'Item'
   const name = profile.value?.name || defaultName
-  document.title = `${name} | 应急二维码`
+  document.title = `${name} | Emergency QR`
 }
 
 const fetchQrDetails = async () => {
@@ -143,7 +147,7 @@ const fetchQrDetails = async () => {
 
 const handleQrSubmit = () => {
   if (!queryInput.value.trim()) {
-    toast.add({ severity: 'warn', summary: '提示', detail: '请先输入二维码编号', life: 2600 })
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter a QR code first.', life: 2600 })
     return
   }
   router.push({ name: 'scan', params: { qrCode: queryInput.value.trim() } })
@@ -151,7 +155,7 @@ const handleQrSubmit = () => {
 
 const handleVerifyMaintenance = async () => {
   if (!qrCode.value || !maintenanceCode.value) {
-    toast.add({ severity: 'warn', summary: '提示', detail: '请输入维护码', life: 2400 })
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please enter the maintenance code.', life: 2400 })
     return
   }
   loading.verifying = true
@@ -164,14 +168,14 @@ const handleVerifyMaintenance = async () => {
     showMaintenancePanel.value = false
     showMaintenanceDialog.value = false
     isEditing.value = true
-    toast.add({ severity: 'success', summary: '验证成功', detail: '已解锁信息维护', life: 2600 })
-    // 首次填写时自动滚动到表单
+    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(scrollToForm, 300)
     }
   } catch (error) {
     maintenancePassed.value = false
-    toast.add({ severity: 'error', summary: '验证失败', detail: parseError(error), life: 3200 })
+    toast.add({ severity: 'error', summary: 'Verification failed', detail: parseError(error), life: 3200 })
   } finally {
     loading.verifying = false
   }
@@ -202,14 +206,14 @@ const handleSaveProfile = async () => {
     if (isPet.value) updater = updatePetProfileApi
     if (isGoods.value) updater = updateGoodsInfoApi
     await updater(payload)
-    toast.add({ severity: 'success', summary: '保存成功', detail: '资料已更新', life: 3000 })
+    toast.add({ severity: 'success', summary: 'Saved', detail: 'Profile has been updated.', life: 3000 })
     await fetchQrDetails()
-    // 保存成功后退出编辑模式
+    // Exit edit mode after a successful save
     isEditing.value = false
     maintenancePassed.value = false
     maintenanceCode.value = ''
   } catch (error) {
-    toast.add({ severity: 'error', summary: '保存失败', detail: parseError(error), life: 3200 })
+    toast.add({ severity: 'error', summary: 'Save failed', detail: parseError(error), life: 3200 })
   } finally {
     loading.saving = false
   }
@@ -221,8 +225,8 @@ const triggerPhotoPicker = () => {
 }
 
 const handleImageError = (event) => {
-  // 图片加载失败时的处理
-  console.warn('图片加载失败:', formData.photoUrl)
+  // Handle broken image preview
+  console.warn('Image load failed:', formData.photoUrl)
   event.target.style.display = 'none'
 }
 
@@ -230,35 +234,35 @@ const handlePhotoChange = async (event) => {
   const file = event.target.files?.[0]
   if (!file) return
 
-  // 验证文件类型
+  // Validate mime type
   if (!file.type.startsWith('image/')) {
-    toast.add({ severity: 'warn', summary: '提示', detail: '请选择图片文件', life: 2400 })
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Please choose an image file.', life: 2400 })
     return
   }
 
-  // 验证文件大小(限制为 15MB)
+  // Validate file size (limit 15MB)
   const maxSize = 15 * 1024 * 1024
   if (file.size > maxSize) {
-    toast.add({ severity: 'warn', summary: '提示', detail: '图片大小不能超过 15MB', life: 2400 })
+    toast.add({ severity: 'warn', summary: 'Notice', detail: 'Image size cannot exceed 15MB.', life: 2400 })
     return
   }
 
   loading.photo = true
   try {
     const response = await uploadFile(file)
-    // 根据返回的数据结构获取 URL
+    // Normalize upload response shape
     const url = response?.data?.url || response?.url || ''
     if (url) {
       formData.photoUrl = url
-      toast.add({ severity: 'success', summary: '上传成功', detail: '图片已更新', life: 2400 })
+      toast.add({ severity: 'success', summary: 'Uploaded', detail: 'Photo has been updated.', life: 2400 })
     } else {
-      throw new Error('未获取到图片地址')
+      throw new Error('Image URL not found')
     }
   } catch (error) {
-    toast.add({ severity: 'error', summary: '上传失败', detail: parseError(error), life: 3200 })
+    toast.add({ severity: 'error', summary: 'Upload failed', detail: parseError(error), life: 3200 })
   } finally {
     loading.photo = false
-    // 清空 input,允许重复上传同一文件
+    // Reset input so the same file can trigger change
     if (event.target) {
       event.target.value = ''
     }
@@ -282,6 +286,33 @@ const sendEmail = (email) => {
   window.location.href = `mailto:${email}`
 }
 
+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
   profile.value = null
@@ -317,13 +348,43 @@ watch([qrType, () => profile.value?.name], () => {
   setDocumentTitle()
 })
 
-// 首次填写时自动显示验证弹窗
+// Auto open verification dialog on first fill
 watch(isFirstFill, (value) => {
   if (value && !maintenancePassed.value) {
     showMaintenanceDialog.value = true
   }
 })
 
+const handleOpenLocationDialog = () => {
+  showLocationDialog.value = true
+}
+
+const handleSaveLocation = (location) => {
+  formData.location = location
+  toast.add({ severity: 'success', summary: 'Saved', detail: 'Address selected.', life: 2400 })
+}
+
+const handleOpenLocationView = () => {
+  showLocationViewDialog.value = true
+}
+
+const openGoogleMaps = () => {
+  const location = isPerson.value ? null : profile.value?.location
+  if (!location) return
+
+  // Try to parse coordinate format (lat, lng)
+  const coordsMatch = location.match(/(-?\d+\.?\d*),\s*(-?\d+\.?\d*)/)
+  if (coordsMatch) {
+    const lat = coordsMatch[1]
+    const lng = coordsMatch[2]
+    // Open Google Maps with coordinates
+    window.open(`https://www.google.com/maps?q=${lat},${lng}`, '_blank')
+  } else {
+    // Otherwise search by address string
+    window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(location)}`, '_blank')
+  }
+}
+
 onMounted(() => {
   if (!qrCode.value) {
     setDocumentTitle()
@@ -353,15 +414,15 @@ onMounted(() => {
               <i class="pi pi-qrcode text-2xl text-cyan-300" />
             </div>
             <div class="flex-1">
-              <p class="text-lg font-semibold text-white">欢迎使用应急二维码系统</p>
+              <p class="text-lg font-semibold text-white">Welcome to the Emergency 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="输入二维码编号"
+              <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">
@@ -370,14 +431,14 @@ onMounted(() => {
                   <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">例如:<span
+                <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>
@@ -385,14 +446,14 @@ onMounted(() => {
         <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">请检查二维码编号是否正确,或联系管理员获取帮助。</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="loading.info" class="rounded-3xl border border-white/10 bg-white/5 p-6 text-base text-white">
-            正在加载二维码信息...
+            Loading QR information...
           </div>
 
-          <!-- 只有验证通过后才显示内容 -->
+          <!-- Only render details after maintenance verification -->
           <div v-if="infoStatus.state === 'ready' && qrDetail && (maintenancePassed || hasProfile)" class="space-y-6">
             <div v-if="hasProfile && !isEditing"
               class="rounded-3xl border border-white/10 bg-white text-slate-900 shadow-2xl shadow-cyan-500/10 relative">
@@ -406,12 +467,12 @@ onMounted(() => {
                         @error="(e) => e.target.style.display = 'none'" />
                       <div v-else class="flex h-full w-full flex-col items-center justify-center gap-2 text-slate-400">
                         <i class="pi pi-image text-4xl" />
-                        <span class="text-sm">无照片</span>
+                        <span class="text-sm">No photo</span>
                       </div>
-                      <!-- 编辑按钮 -->
+                      <!-- Edit button -->
                       <button v-if="!isEditing" type="button"
                         class="absolute top-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-white/90 backdrop-blur-sm text-slate-600 hover:bg-white hover:text-slate-900 transition-all shadow-lg hover:shadow-xl"
-                        @click="showMaintenanceDialog = true" title="编辑资料">
+                        @click="showMaintenanceDialog = true" title="Edit profile">
                         <i class="pi pi-pencil text-xs" />
                       </button>
                     </div>
@@ -422,49 +483,81 @@ onMounted(() => {
                   <div class="space-y-4">
                     <div>
                       <p class="mt-1 text-sm text-slate-500">
-                        {{ isPerson ? '紧急联系人卡' : isGoods ? '物品信息' : '宠物信息' }}
+                        {{ isPerson ? 'Emergency contact card' : isGoods ? 'Item information' : 'Pet information' }}
                       </p>
                       <p class="text-2xl font-semibold text-slate-900">
-                        {{ profile?.name || '未命名' }}
+                        {{ profile?.name || 'Unnamed' }}
                       </p>
                     </div>
                     <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-2">
-                        <div class="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
-                          <i class="pi pi-user text-sm text-emerald-600" />
+                      <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-user text-lg text-emerald-600" />
                         </div>
-                        <p class="text-xs font-medium uppercase tracking-wider text-slate-500">{{ isPerson ? '紧急联系人' :
-                          '联系人' }}</p>
+                        <p class="text-[11px] font-semibold uppercase tracking-[0.35em] text-slate-500">{{ isPerson ?
+                          'EMERGENCY CONTACT' : 'CONTACT' }}</p>
                       </div>
-                      <p class="mt-3 text-xl font-semibold text-slate-900">
+                      <p class="mt-3 cursor-pointer text-3xl font-semibold leading-tight text-slate-800" title="Click to copy name"
+                        @click="copyInfo(isPerson ? profile?.emergencyContactName : profile?.contactName, 'Name')">
                         {{ isPerson ? profile?.emergencyContactName || '-' : profile?.contactName || '-' }}
                       </p>
-                      <div class="mt-3 space-y-1.5 text-sm text-slate-600">
-                        <div class="flex items-center gap-2">
-                          <i class="pi pi-phone text-xs text-slate-400" />
-                          <span>{{ isPerson ? profile?.emergencyContactPhone || '-' : profile?.contactPhone || '-'
-                            }}</span>
+                      <div class="mt-4 space-y-3 text-sm text-slate-600">
+                        <div class="flex items-center gap-3">
+                          <i class="pi pi-phone text-lg text-slate-500" />
+                          <div class="leading-tight cursor-pointer select-text text-slate-600" title="Click to copy phone"
+                            @click="copyInfo(isPerson ? profile?.emergencyContactPhone : profile?.contactPhone, 'Phone')">
+                            <p class="text-[10px] uppercase tracking-[0.4em] text-slate-400">PHONE</p>
+                            <p class="text-base font-semibold text-slate-700">
+                              {{ isPerson ? profile?.emergencyContactPhone || '-' : profile?.contactPhone || '-' }}
+                            </p>
+                          </div>
                         </div>
                         <div v-if="(isPerson ? profile?.emergencyContactEmail : profile?.contactEmail)"
-                          class="flex items-center gap-2">
-                          <i class="pi pi-envelope text-xs text-slate-400" />
-                          <span class="truncate">{{ isPerson ? profile?.emergencyContactEmail : profile?.contactEmail
-                            }}</span>
+                          class="flex items-center gap-3">
+                          <i class="pi pi-envelope text-lg text-slate-500" />
+                          <div class="leading-tight min-w-0 cursor-pointer select-text text-slate-600" title="Click to copy email"
+                            @click="copyInfo(isPerson ? profile?.emergencyContactEmail : profile?.contactEmail, 'Email')">
+                            <p class="text-[10px] uppercase tracking-[0.4em] text-slate-400">EMAIL</p>
+                            <p class="truncate text-base font-semibold text-slate-700">
+                              {{ isPerson ? profile?.emergencyContactEmail : profile?.contactEmail }}
+                            </p>
+                          </div>
+                        </div>
+                        <div v-if="!isPerson && profile?.location" class="flex items-start gap-3">
+                          <i class="pi pi-map-marker text-lg text-slate-500 mt-0.5 flex-shrink-0" />
+                          <div class="leading-tight cursor-pointer select-text text-slate-600" title="Click to copy address"
+                            @click="copyInfo(profile?.location, 'Address')">
+                            <p class="text-[10px] uppercase tracking-[0.4em] text-slate-400">LOCATION</p>
+                            <p class="flex-1 break-words whitespace-normal text-base font-medium text-slate-700">
+                              {{ profile.location }}
+                            </p>
+                          </div>
                         </div>
                       </div>
-                      <div class="mt-4 flex flex-wrap gap-2">
+                      <div class="mt-4 grid grid-cols-2 gap-2">
                         <button v-if="profile?.emergencyContactPhone || profile?.contactPhone" type="button"
-                          class="inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 transition hover:bg-emerald-100"
+                          class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 transition hover:bg-emerald-100"
                           @click="callNumber(isPerson ? profile?.emergencyContactPhone : profile?.contactPhone)">
-                          <i class="pi pi-phone" /> 拨打
+                          <i class="pi pi-phone" /> Call
                         </button>
                         <button
                           v-if="(isPerson && profile?.emergencyContactEmail) || (!isPerson && profile?.contactEmail)"
                           type="button"
-                          class="inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 transition hover:bg-blue-100"
+                          class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 transition hover:bg-blue-100"
                           @click="sendEmail(isPerson ? profile?.emergencyContactEmail : profile?.contactEmail)">
-                          <i class="pi pi-envelope" /> 邮件
+                          <i class="pi pi-envelope" /> Email
+                        </button>
+                        <button v-if="!isPerson && profile?.location" type="button"
+                          class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-purple-200 bg-purple-50 px-3 py-1.5 text-xs font-medium text-purple-700 transition hover:bg-purple-100"
+                          @click="handleOpenLocationView">
+                          <i class="pi pi-map" /> Map
+                        </button>
+                        <button v-if="!isPerson && profile?.location" type="button"
+                          class="inline-flex w-full items-center justify-center gap-1.5 rounded-full border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 transition hover:bg-red-100"
+                          @click="openGoogleMaps">
+                          <i class="pi pi-external-link" /> Google Maps
                         </button>
                       </div>
                     </div>
@@ -473,11 +566,11 @@ onMounted(() => {
                         <div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-100">
                           <i class="pi pi-info-circle text-sm text-amber-600" />
                         </div>
-                        <p class="text-xs font-medium uppercase tracking-wider text-slate-500">{{ isPerson ? '特别说明' :
-                          isGoods ? '备注说明' : '补充描述' }}</p>
+                        <p class="text-xs font-medium uppercase tracking-wider text-slate-500">{{ isPerson ? 'Additional notes' :
+                          isGoods ? 'Item notes' : 'Extra description' }}</p>
                       </div>
                       <p class="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
-                        {{ isPerson ? (profile?.specialNote || '暂无说明') : (profile?.remark || '暂无说明') }}
+                        {{ isPerson ? (profile?.specialNote || 'No extra details') : (profile?.remark || 'No extra details') }}
                       </p>
                     </div>
                   </div>
@@ -487,18 +580,17 @@ onMounted(() => {
           </div>
         </section>
 
-        <!-- 只有验证通过后才显示表单 -->
         <section v-if="qrCode && maintenancePassed" id="scan-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">Profile setup</p>
               <h2 class="mt-1 text-2xl font-semibold text-slate-900">
-                {{ hasProfile ? '更新资料' : '首次填写资料' }}
+                {{ hasProfile ? 'Update profile' : 'First-time setup' }}
               </h2>
             </div>
             <p class="text-sm text-slate-500">
-              二维码:<span class="font-mono text-slate-700">{{ qrCode }}</span>
+              QR Code: <span class="font-mono text-slate-700">{{ qrCode }}</span>
             </p>
           </div>
 
@@ -506,9 +598,10 @@ onMounted(() => {
             <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">首次填写提示</p>
+                <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>
@@ -517,9 +610,9 @@ onMounted(() => {
             <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">编辑模式</p>
+                <p class="font-semibold">Edit mode</p>
                 <p class="mt-1">
-                  您已通过维护码验证,现在可以修改资料了。修改后请记得点击保存按钮。
+                  Maintenance code verified. You can now edit the profile—remember to save your changes.
                 </p>
               </div>
             </div>
@@ -528,8 +621,8 @@ onMounted(() => {
           <form class="mt-6 space-y-6" @submit.prevent="handleSaveProfile">
             <div class="space-y-4">
               <div>
-                <p class="text-sm font-medium text-slate-700">展示头像 / 实物图片</p>
-                <p class="mt-1 text-xs text-slate-500">建议尺寸 640×640 以上,支持 JPG、PNG,最大 15MB</p>
+                <p class="text-sm font-medium text-slate-700">Display photo / item image</p>
+                <p class="mt-1 text-xs text-slate-500">Recommended 640×640+, supports JPG/PNG, up to 15MB.</p>
               </div>
               <div
                 class="relative w-full max-w-md mx-auto overflow-hidden rounded-2xl bg-slate-100 ring-2 ring-slate-200"
@@ -542,7 +635,7 @@ onMounted(() => {
                 <div v-else-if="!formData.photoUrl"
                   class="flex h-full w-full flex-col items-center justify-center gap-2 text-slate-400">
                   <i class="pi pi-image text-5xl" />
-                  <span class="text-sm">无图片</span>
+                  <span class="text-sm">No image</span>
                 </div>
               </div>
               <input ref="photoInputRef" type="file" accept="image/jpeg,image/jpg,image/png,image/webp" class="hidden"
@@ -552,31 +645,31 @@ onMounted(() => {
                   class="rounded-full border border-slate-300 bg-white px-6 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:opacity-50"
                   :disabled="loading.photo" @click="triggerPhotoPicker">
                   <i class="pi mr-2" :class="loading.photo ? 'pi-spin pi-spinner' : 'pi-upload'" />
-                  {{ loading.photo ? '上传中...' : '上传图片' }}
+                  {{ loading.photo ? 'Uploading...' : 'Upload image' }}
                 </button>
                 <button v-if="formData.photoUrl" type="button"
                   class="rounded-full border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100"
                   @click="formData.photoUrl = ''">
                   <i class="pi pi-times mr-1" />
-                  清除图片
+                  Clear image
                 </button>
               </div>
             </div>
 
             <div class="grid gap-4 md:grid-cols-2">
               <label class="space-y-2 text-sm">
-                <span class="text-slate-500">{{ isPerson ? '姓名' : isGoods ? '物品名称' : '宠物名称' }}</span>
+                <span class="text-slate-500">{{ isPerson ? 'Name' : isGoods ? 'Item name' : 'Pet name' }}</span>
                 <input v-model="formData.name" type="text" class="w-full rounded-2xl border border-slate-200 px-4 py-3"
-                  :placeholder="isPerson ? '例如:张三' : isGoods ? '例如:苹果笔记本电脑' : '例如:小白'" required />
+                  :placeholder="isPerson ? 'e.g. John Doe' : isGoods ? 'e.g. MacBook Pro' : 'e.g. Snowy'" required />
               </label>
 
               <label v-if="isPerson" class="space-y-2 text-sm">
-                <span class="text-slate-500">性别</span>
+                <span class="text-slate-500">Gender</span>
                 <div class="flex gap-2">
                   <button v-for="option in [
-                    { label: '', value: 'male' },
-                    { label: '', value: 'female' },
-                    { label: '保密', value: 'unknown' }
+                    { label: 'Male', value: 'male' },
+                    { label: 'Female', value: 'female' },
+                    { label: 'Prefer not to say', value: 'unknown' }
                   ]" :key="option.value" type="button" class="flex-1 rounded-2xl border px-4 py-3 text-sm"
                     :class="formData.gender === option.value ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 text-slate-500'"
                     @click="formData.gender = option.value">
@@ -588,67 +681,82 @@ onMounted(() => {
 
             <div v-if="isPerson" class="grid gap-4 md:grid-cols-2">
               <label class="space-y-2 text-sm">
-                <span class="text-slate-500">本人电话</span>
+                <span class="text-slate-500">Owner phone</span>
                 <input v-model="formData.phone" type="tel" class="w-full rounded-2xl border border-slate-200 px-4 py-3"
                   required />
               </label>
 
               <label class="space-y-2 text-sm">
-                <span class="text-slate-500">紧急联系人姓名</span>
+                <span class="text-slate-500">Emergency contact name</span>
                 <input v-model="formData.emergencyContactName" type="text"
                   class="w-full rounded-2xl border border-slate-200 px-4 py-3" required />
               </label>
 
               <label class="space-y-2 text-sm">
-                <span class="text-slate-500">紧急联系人电话</span>
+                <span class="text-slate-500">Emergency contact phone</span>
                 <input v-model="formData.emergencyContactPhone" type="tel"
                   class="w-full rounded-2xl border border-slate-200 px-4 py-3" required />
               </label>
 
               <label class="space-y-2 text-sm md:col-span-2">
-                <span class="text-slate-500">紧急联系人邮箱</span>
+                <span class="text-slate-500">Emergency contact email</span>
                 <input v-model="formData.emergencyContactEmail" type="email"
-                  class="w-full rounded-2xl border border-slate-200 px-4 py-3" placeholder="可选" />
+                  class="w-full rounded-2xl border border-slate-200 px-4 py-3" placeholder="Optional" />
               </label>
             </div>
 
             <div v-else class="grid gap-4 md:grid-cols-2">
               <label class="space-y-2 text-sm">
-                <span class="text-slate-500">联系人姓名</span>
+                <span class="text-slate-500">Contact name</span>
                 <input v-model="formData.contactName" type="text"
                   class="w-full rounded-2xl border border-slate-200 px-4 py-3" required />
               </label>
 
               <label class="space-y-2 text-sm">
-                <span class="text-slate-500">联系人电话</span>
+                <span class="text-slate-500">Contact phone</span>
                 <input v-model="formData.contactPhone" type="tel"
                   class="w-full rounded-2xl border border-slate-200 px-4 py-3" required />
               </label>
 
               <label class="space-y-2 text-sm md:col-span-2">
-                <span class="text-slate-500">联系人邮箱</span>
+                <span class="text-slate-500">Contact email</span>
                 <input v-model="formData.contactEmail" type="email"
-                  class="w-full rounded-2xl border border-slate-200 px-4 py-3" placeholder="可选" />
+                  class="w-full rounded-2xl border border-slate-200 px-4 py-3" placeholder="Optional" />
+              </label>
+
+              <label class="space-y-2 text-sm md:col-span-2">
+                <span class="text-slate-500">Contact address</span>
+                <div class="flex gap-2">
+                  <input v-model="formData.location" type="text" readonly
+                    class="flex-1 rounded-2xl border border-slate-200 px-4 py-3 bg-slate-50 cursor-pointer"
+                    placeholder="Click to select address" @click="handleOpenLocationDialog" />
+                  <button type="button"
+                    class="rounded-2xl border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50 whitespace-nowrap"
+                    @click="handleOpenLocationDialog">
+                    <i class="pi pi-map-marker mr-2" />
+                    Select address
+                  </button>
+                </div>
               </label>
             </div>
 
             <label class="block space-y-2 text-sm">
-              <span class="text-slate-500">{{ isPerson ? '特别说明 / 健康提示' : isGoods ? '备注说明' : '补充描述 / 备注' }}</span>
+              <span class="text-slate-500">{{ isPerson ? 'Additional notes / health tips' : isGoods ? 'Item notes' : 'Extra remarks' }}</span>
               <textarea v-if="isPerson" v-model="formData.specialNote" rows="4"
-                class="w-full rounded-2xl border border-slate-200 px-4 py-3" placeholder="例如过敏药物、携带物品等" />
+                class="w-full rounded-2xl border border-slate-200 px-4 py-3" placeholder="e.g. allergies, medical needs, carried items" />
               <textarea v-else v-model="formData.remark" rows="4"
                 class="w-full rounded-2xl border border-slate-200 px-4 py-3"
-                :placeholder="isGoods ? '例如物品特征、使用说明、联系注意事项等' : '例如宠物习性、健康状况等'" />
+                :placeholder="isGoods ? 'e.g. item features, usage notes, handling tips' : 'e.g. pet habits, health info, behavior notes'" />
             </label>
 
             <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
               <p class="text-sm text-slate-500">
-                提交后将同步到扫码页面。
+                Submitted data will sync to the public scan page.
               </p>
               <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 ? '保存中...' : hasProfile ? '保存更新' : '提交资料' }}
+                {{ loading.saving ? 'Saving...' : hasProfile ? 'Save changes' : 'Submit profile' }}
               </button>
             </div>
           </form>
@@ -656,16 +764,23 @@ onMounted(() => {
 
         <footer class="mt-8 border-t border-white/10 pt-6 text-center">
           <p class="text-xs text-slate-400">
-            数据仅用于紧急联系场景,上传即视为同意存储并用于扫码展示
+            Data is used solely for emergency contact scenarios. Uploading means you consent to display it when scanned.
           </p>
           <p class="mt-2 text-xs text-slate-500">
-            <i class="pi pi-shield text-cyan-400" /> 您的隐私受到保护
+            <i class="pi pi-shield text-cyan-400" /> Your privacy stays protected
           </p>
         </footer>
       </div>
     </div>
 
-    <!-- 维护码验证弹窗 -->
+    <!-- Location picker dialog -->
+    <LocationPicker v-model="showLocationDialog" :initial-location="formData.location" @save="handleSaveLocation" />
+
+    <!-- Location preview dialog (read-only) -->
+    <LocationPicker v-model="showLocationViewDialog" :initial-location="isPerson ? null : profile?.location"
+      :closable="true" :readonly="true" />
+
+    <!-- Maintenance code dialog -->
     <Dialog v-model:visible="showMaintenanceDialog" modal :closable="!isFirstFill" :closeOnEscape="!isFirstFill"
       :dismissableMask="!isFirstFill" :style="{ width: '90vw', maxWidth: '450px' }" :draggable="false">
       <template #header>
@@ -675,10 +790,10 @@ onMounted(() => {
           </div>
           <div>
             <h3 class="text-lg font-semibold text-slate-900">
-              {{ isFirstFill ? '首次使用需要验证' : '编辑资料需要验证' }}
+              {{ isFirstFill ? 'Verification required for first use' : 'Verification required to edit' }}
             </h3>
             <p class="text-sm text-slate-500">
-              {{ isFirstFill ? '请输入二维码附带的维护码' : '请输入维护码以解锁编辑权限' }}
+              {{ isFirstFill ? 'Enter the maintenance code that came with the QR tag.' : 'Enter the maintenance code to unlock editing.' }}
             </p>
           </div>
         </div>
@@ -686,16 +801,16 @@ onMounted(() => {
 
       <div class="space-y-4 py-4">
         <div>
-          <label class="mb-2 block text-sm font-medium text-slate-700">维护码</label>
+          <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="输入 8 位维护码" @keyup.enter="handleVerifyMaintenance" autofocus />
+            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>
@@ -706,12 +821,12 @@ onMounted(() => {
           <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 ? '验证中...' : '验证' }}
+            {{ loading.verifying ? 'Verifying...' : 'Verify' }}
           </button>
         </div>
       </template>

+ 5 - 0
yarn.lock

@@ -1932,6 +1932,11 @@ kolorist@^1.8.0:
   resolved "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz"
   integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
 
+leaflet@^1.9.4:
+  version "1.9.4"
+  resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d"
+  integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==
+
 less@^4.2.2:
   version "4.2.2"
   resolved "https://registry.npmmirror.com/less/-/less-4.2.2.tgz"