Просмотр исходного кода

feat: 巡检/质量巡检任务详情补位置显示与定位

- pages/inspection/check: 详情页补 punchLocation(实际打卡位置),itemCount 字段,巡检项目列表渲染
- pages/inspection/scan: 扫码页相关
- pages/qualityInspection/check: 质量巡检任务详情补位置显示
- pages/qualityInspection/scan: 扫码页相关
- utils/location.ts: 定位工具
- vite.config.ts / package.json / package-lock.json: 依赖与配置调整
- .gitignore: 排除本地 HTTPS 调试私钥(cert/、localhost+4*.pem、serve-http.js)
yangjingjing 1 день назад
Родитель
Сommit
0e9403c832

+ 13 - 0
package-lock.json

@@ -23,6 +23,7 @@
         "@dcloudio/uni-cli-shared": "3.0.0-4030620241128001",
         "@dcloudio/uni-stacktracey": "3.0.0-4030620241128001",
         "@dcloudio/vite-plugin-uni": "3.0.0-4030620241128001",
+        "@vitejs/plugin-basic-ssl": "^1.2.0",
         "@vue/runtime-core": "^3.4.21",
         "sass": "^1.69.5",
         "typescript": "^5.4.5",
@@ -4425,6 +4426,18 @@
       "dev": true,
       "peer": true
     },
+    "node_modules/@vitejs/plugin-basic-ssl": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz",
+      "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=14.21.3"
+      },
+      "peerDependencies": {
+        "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
+      }
+    },
     "node_modules/@vitejs/plugin-legacy": {
       "version": "5.3.2",
       "resolved": "https://registry.npmmirror.com/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz",

+ 1 - 0
package.json

@@ -26,6 +26,7 @@
     "@dcloudio/uni-cli-shared": "3.0.0-4030620241128001",
     "@dcloudio/uni-stacktracey": "3.0.0-4030620241128001",
     "@dcloudio/vite-plugin-uni": "3.0.0-4030620241128001",
+    "@vitejs/plugin-basic-ssl": "^1.2.0",
     "@vue/runtime-core": "^3.4.21",
     "sass": "^1.69.5",
     "typescript": "^5.4.5",

+ 2 - 2
src/manifest.json

@@ -59,7 +59,7 @@
       "https": false,
       "proxy": {
         "/dev-api": {
-          "target": "http://127.0.0.1:8080",
+          "target": "http://127.0.0.1:9225",
           "changeOrigin": true,
           "secure": false
         }
@@ -68,7 +68,7 @@
     "sdkConfigs": {
       "maps": {
         "qqmap": {
-          "key": ""
+          "key": "I4OBZ-ULG6Q-JXZ5N-4HSCN-PE4AZ-3TFRU"
         }
       }
     }

+ 30 - 12
src/pages/inspection/check/index.vue

@@ -114,7 +114,7 @@ import type { InspectionTask, InspectionItem } from '@/types/inspection'
 import { getInspectionTaskDetail, submitPunch, uploadPhoto } from '@/api/inspection'
 import { request } from '@/utils/request'
 import { getToken } from '@/utils/auth'
-import { getCurrentLocation, ensureLocationAuth } from '@/utils/location'
+import { getCurrentLocation, ensureLocationAuth, pickLocationFallback } from '@/utils/location'
 
 const taskId = ref<string>('')
 const readonly = ref<boolean>(false)
@@ -149,7 +149,13 @@ async function loadDetail() {
     // 走统一 request 后:已自动剥外层 R<T>,data 即 InspectionTask
     const data = (await getInspectionTaskDetail(taskId.value)) as InspectionTask
     if (data) {
-      task.value = data
+      // 详情接口字段是 location(后端 VO),模板用的是 punchLocation(列表接口 mapRow 映射后的字段名)
+      // 这里补一次映射,让已打卡任务的预设位置能在 readonly 模式下显示
+      const raw: any = data
+      task.value = {
+        ...data,
+        punchLocation: raw.punchLocation || raw.location || ''
+      }
       items.value = (data.items || []).map((it) => ({
         ...it,
         needPhoto: it.needPhoto === '1' || it.needPhoto === true,
@@ -266,16 +272,15 @@ function delPhoto(item: InspectionItem, idx: number) {
 }
 
 /**
- * 调起 uni.chooseLocation 选点
- * - 用户在地图上点选 → 写入 pickedLocation + pickedCoord
- * - 取消选择 → 保持原值
+ * 调起选点
+ * - 优先走 uni.chooseLocation(腾讯地图 SDK,H5 端依赖 manifest.json 的 qqmap.key)
+ * - 失败/取消 → fallback 用浏览器原生 geolocation + 让用户手输位置名
+ *   (防止 key 失效/限额/未配时的体验)
  */
 function onPickLocation() {
   if (readonly.value) return
-  // #ifdef APP-PLUS || MP-WEIXIN || H5
   uni.chooseLocation({
     success: (res: any) => {
-      // res.address(详细地址) / res.name(地点名) / res.latitude / res.longitude
       const name = (res.name || res.address || '').toString().trim()
       if (name) pickedLocation.value = name
       if (typeof res.latitude === 'number' && typeof res.longitude === 'number') {
@@ -283,13 +288,26 @@ function onPickLocation() {
       }
     },
     fail: (err: any) => {
-      // 用户取消选择不提示,其他错误给出轻量提示
-      if (err && err.errMsg && !/cancel|deny/i.test(err.errMsg)) {
-        uni.showToast({ title: '选点失败,请重试', icon: 'none' })
-      }
+      // 用户取消选点不提示;其他错误(SDK 失败/key 失效) → fallback
+      if (err && err.errMsg && /cancel|deny/i.test(err.errMsg)) return
+      // #ifdef H5
+      // H5 端 key 失效时降级:浏览器原生 geolocation + 弹窗输入位置名
+      pickLocationFallback()
+        .then((r) => {
+          pickedLocation.value = r.name
+          if (r.lng || r.lat) pickedCoord.value = { lng: r.lng, lat: r.lat }
+        })
+        .catch((e) => {
+          if (e?.message !== 'cancel') {
+            uni.showToast({ title: e?.message || '选点失败,请重试', icon: 'none' })
+          }
+        })
+      // #endif
+      // #ifndef H5
+      uni.showToast({ title: '选点失败,请重试', icon: 'none' })
+      // #endif
     }
   })
-  // #endif
 }
 
 function validate(): { ok: boolean; msg: string } {

+ 149 - 9
src/pages/inspection/scan/index.vue

@@ -24,8 +24,30 @@
       <text>{{ tipText }}</text>
     </view>
 
-    <view class="scan-btn-row">
+    <!-- 操作按钮组 -->
+    <view class="scan-btn-row scan-btn-group">
       <button class="btn btn-default" @tap="onManualInput">手动输入</button>
+      <button v-if="showPhotoScan" class="btn btn-default" @tap="onPhotoScan">拍照扫码</button>
+    </view>
+
+    <!-- 隐藏的 file input(用于微信等不支持 getUserMedia 的浏览器) -->
+    <!-- #ifdef H5 -->
+    <input
+      ref="fileInputRef"
+      type="file"
+      accept="image/*"
+      capture="environment"
+      style="display:none"
+      @change="onFileSelected"
+    />
+    <!-- #endif -->
+
+    <!-- 点击重试按钮(仅 iOS getUserMedia 因手势问题失败时显示) -->
+    <view v-if="scanRetry" class="scan-retry-overlay" @tap="onRetryScan">
+      <view class="scan-retry-btn">
+        <text class="scan-retry-icon">📷</text>
+        <text class="scan-retry-text">点击重试扫码</text>
+      </view>
     </view>
   </view>
 </template>
@@ -45,15 +67,90 @@ import jsQR from 'jsqr'
 const taskId = ref<number>(0)
 const scanResult = ref<string>('')
 const tipText = ref<string>('请将摄像头对准巡检点位的二维码')
+const scanRetry = ref<boolean>(false)
+const showPhotoScan = ref<boolean>(false)
+const fileInputRef = ref<any>(null)
+
+let scanTimer: any = null
+let h5Stream: MediaStream | null = null
+let h5RafId: number | null = null
 
 onLoad((q) => {
   taskId.value = Number(q?.taskId || 0)
+  // 自动启动扫码(大部分设备直接可用)
+  // iOS Safari 若因手势问题失败,会显示"点击重试"按钮
   startScan()
 })
 
-let scanTimer: any = null
-let h5Stream: MediaStream | null = null
-let h5RafId: number | null = null
+// H5:用户点击重试扫码(确保 getUserMedia 在用户手势上下文中调用)
+function onRetryScan() {
+  scanRetry.value = false
+  startScan()
+}
+
+// H5:拍照扫码(通过 file input 拍照,适用于微信等不支持 getUserMedia 的浏览器)
+function onPhotoScan() {
+  if (fileInputRef.value) {
+    fileInputRef.value.value = ''
+    fileInputRef.value.click()
+  }
+}
+
+// H5:file input 选择图片后的回调
+async function onFileSelected(e: any) {
+  const file = e?.target?.files?.[0]
+  if (!file) return
+  try {
+    tipText.value = '正在识别二维码…'
+    const img = await loadImageFromFile(file)
+    const code = decodeQrFromImage(img)
+    if (code) {
+      h5StopScanner()
+      scanResult.value = code
+      onScanSuccess(code)
+    } else {
+      tipText.value = '未识别到二维码,请重新拍照'
+      uni.showToast({ title: '未识别到二维码', icon: 'none' })
+    }
+  } catch (err) {
+    tipText.value = '图片识别失败,请重试'
+    console.warn('[拍照扫码] 识别失败', err)
+  }
+}
+
+// H5:从 File 加载 Image 对象
+function loadImageFromFile(file: File): Promise<HTMLImageElement> {
+  return new Promise((resolve, reject) => {
+    const url = URL.createObjectURL(file)
+    const img = new Image()
+    img.onload = () => { URL.revokeObjectURL(url); resolve(img) }
+    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')) }
+    img.src = url
+  })
+}
+
+// H5:用 jsQR 从图片中解码二维码
+function decodeQrFromImage(img: HTMLImageElement): string | null {
+  const canvas = document.createElement('canvas')
+  const MAX = 1024
+  let w = img.naturalWidth
+  let h = img.naturalHeight
+  if (w > MAX || h > MAX) {
+    const ratio = Math.min(MAX / w, MAX / h)
+    w = Math.round(w * ratio)
+    h = Math.round(h * ratio)
+  }
+  canvas.width = w
+  canvas.height = h
+  const ctx = canvas.getContext('2d', { willReadFrequently: true })
+  if (!ctx) return null
+  ctx.drawImage(img, 0, 0, w, h)
+  const imageData = ctx.getImageData(0, 0, w, h)
+  const code = jsQR(imageData.data, imageData.width, imageData.height, {
+    inversionAttempts: 'dontInvert'
+  })
+  return code?.data || null
+}
 
 function startScan() {
   // #ifdef APP-PLUS || MP-WEIXIN
@@ -86,7 +183,8 @@ async function h5StartBarcodeScanner() {
   // 1. 检测摄像头权限 API
   if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
     console.warn('[H5 扫码] 浏览器不支持 getUserMedia')
-    tipText.value = '浏览器不支持获取摄像头,请点击下方"手动输入"'
+    showPhotoScan.value = true
+    tipText.value = '当前浏览器不支持实时扫码,请使用拍照扫码或手动输入'
     return
   }
   try {
@@ -98,11 +196,16 @@ async function h5StartBarcodeScanner() {
     console.log('[H5 扫码] getUserMedia 成功')
   } catch (e: any) {
     if (e?.name === 'NotAllowedError' || e?.name === 'PermissionDeniedError') {
-      tipText.value = '摄像头权限被拒绝,请在浏览器设置中允许后重试'
+      // iOS Safari getUserMedia 需要用户手势,此时显示重试按钮
+      scanRetry.value = true
+      showPhotoScan.value = true
+      tipText.value = '点击下方按钮开始扫码'
     } else if (e?.name === 'NotFoundError') {
-      tipText.value = '未找到可用摄像头,请点击下方"手动输入"'
+      showPhotoScan.value = true
+      tipText.value = '未找到可用摄像头,请使用拍照扫码或手动输入'
     } else {
-      tipText.value = '摄像头启动失败,请点击下方"手动输入"'
+      showPhotoScan.value = true
+      tipText.value = '摄像头启动失败,请使用拍照扫码或手动输入'
     }
     console.warn('[H5 扫码] getUserMedia 失败', e)
     return
@@ -180,6 +283,8 @@ function h5StopScanner() {
 onUnmounted(() => {
   if (scanTimer) clearTimeout(scanTimer)
   h5StopScanner()
+  scanRetry.value = false
+  showPhotoScan.value = false
 })
 
 async function onScanSuccess(qrContent: string) {
@@ -310,7 +415,7 @@ function onManualInput() {
   &.tl { top: 0; left: 0; border-right: none; border-bottom: none; }
   &.tr { top: 0; right: 0; border-left: none; border-bottom: none; }
   &.bl { bottom: 0; left: 0; border-right: none; border-top: none; }
-  &.br { bottom: 0; right: 0; border-right: none; border-top: none; }
+  &.br { bottom: 0; right: 0; border-left: none; border-top: none; }
 }
 .scan-line {
   position: absolute;
@@ -344,6 +449,11 @@ function onManualInput() {
   display: flex;
   justify-content: center;
   z-index: 4;
+  &.scan-btn-group {
+    gap: 24rpx;
+    padding: 0 32rpx;
+    .btn { flex: 1; max-width: 280rpx; }
+  }
   .btn {
     width: 280rpx;
     height: 80rpx;
@@ -354,4 +464,34 @@ function onManualInput() {
     &::after { border: none; }
   }
 }
+
+/* 点击重试按钮(仅 iOS 手势问题失败时显示) */
+.scan-retry-overlay {
+  position: absolute;
+  inset: 0;
+  z-index: 10;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(0, 0, 0, 0.7);
+}
+.scan-retry-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 240rpx;
+  height: 240rpx;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.15);
+  border: 2rpx solid rgba(255, 255, 255, 0.4);
+}
+.scan-retry-icon {
+  font-size: 72rpx;
+  margin-bottom: 12rpx;
+}
+.scan-retry-text {
+  font-size: 28rpx;
+  color: #FFFFFF;
+}
 </style>

+ 18 - 6
src/pages/qualityInspection/check/index.vue

@@ -119,7 +119,7 @@ import {
 } from '@/api/qualityInspection'
 import { request } from '@/utils/request'
 import { getToken } from '@/utils/auth'
-import { getCurrentLocation, ensureLocationAuth } from '@/utils/location'
+import { getCurrentLocation, ensureLocationAuth, pickLocationFallback } from '@/utils/location'
 
 // 后端 Java Long > JS Number 安全整数,这里必须保留字符串
 const taskId = ref<string>('')
@@ -255,7 +255,6 @@ function delPhoto(item: QualityInspectionItem, idx: number) {
 
 function onPickLocation() {
   if (readonly.value) return
-  // #ifdef APP-PLUS || MP-WEIXIN || H5
   uni.chooseLocation({
     success: (res: any) => {
       const name = (res.name || res.address || '').toString().trim()
@@ -265,12 +264,25 @@ function onPickLocation() {
       }
     },
     fail: (err: any) => {
-      if (err && err.errMsg && !/cancel|deny/i.test(err.errMsg)) {
-        uni.showToast({ title: '选点失败,请重试', icon: 'none' })
-      }
+      if (err && err.errMsg && /cancel|deny/i.test(err.errMsg)) return
+      // #ifdef H5
+      // H5 端 key 失效时降级:浏览器原生 geolocation + 弹窗输入位置名
+      pickLocationFallback()
+        .then((r) => {
+          pickedLocation.value = r.name
+          if (r.lng || r.lat) pickedCoord.value = { lng: r.lng, lat: r.lat }
+        })
+        .catch((e) => {
+          if (e?.message !== 'cancel') {
+            uni.showToast({ title: e?.message || '选点失败,请重试', icon: 'none' })
+          }
+        })
+      // #endif
+      // #ifndef H5
+      uni.showToast({ title: '选点失败,请重试', icon: 'none' })
+      // #endif
     }
   })
-  // #endif
 }
 
 function validate(): { ok: boolean; msg: string } {

+ 147 - 6
src/pages/qualityInspection/scan/index.vue

@@ -24,14 +24,36 @@
       <text>{{ tipText }}</text>
     </view>
 
-    <view class="scan-btn-row">
+    <!-- 操作按钮组 -->
+    <view class="scan-btn-row scan-btn-group">
       <button class="btn btn-default" @tap="onManualInput">手动输入</button>
+      <button v-if="showPhotoScan" class="btn btn-default" @tap="onPhotoScan">拍照扫码</button>
+    </view>
+
+    <!-- 隐藏的 file input(用于微信等不支持 getUserMedia 的浏览器) -->
+    <!-- #ifdef H5 -->
+    <input
+      ref="fileInputRef"
+      type="file"
+      accept="image/*"
+      capture="environment"
+      style="display:none"
+      @change="onFileSelected"
+    />
+    <!-- #endif -->
+
+    <!-- 点击重试按钮(仅 iOS getUserMedia 因手势问题失败时显示) -->
+    <view v-if="scanRetry" class="scan-retry-overlay" @tap="onRetryScan">
+      <view class="scan-retry-btn">
+        <text class="scan-retry-icon">📷</text>
+        <text class="scan-retry-text">点击重试扫码</text>
+      </view>
     </view>
   </view>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted } from 'vue'
+import { ref, onUnmounted } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
 import {
   calculateDistance,
@@ -45,6 +67,9 @@ import jsQR from 'jsqr'
 const taskId = ref<number>(0)
 const scanResult = ref<string>('')
 const tipText = ref<string>('请将摄像头对准品控巡检点位的二维码')
+const scanRetry = ref<boolean>(false)
+const showPhotoScan = ref<boolean>(false)
+const fileInputRef = ref<any>(null)
 let scanTimer: any = null
 let h5Stream: MediaStream | null = null
 let h5RafId: number | null = null
@@ -57,9 +82,82 @@ onLoad((q) => {
   taskId.value = Number(q?.taskId || 0)
   taskLng = Number(uni.getStorageSync(`qtask_lng_${taskId.value}`) || 0)
   taskLat = Number(uni.getStorageSync(`qtask_lat_${taskId.value}`) || 0)
+  // 自动启动扫码(大部分设备直接可用)
+  // iOS Safari 若因手势问题失败,会显示"点击重试"按钮
   startScan()
 })
 
+// H5:拍照扫码(通过 file input 拍照,适用于微信等不支持 getUserMedia 的浏览器)
+function onPhotoScan() {
+  if (fileInputRef.value) {
+    fileInputRef.value.value = '' // 清空以允许重复选择同一文件
+    fileInputRef.value.click()
+  }
+}
+
+// H5:file input 选择图片后的回调
+async function onFileSelected(e: any) {
+  const file = e?.target?.files?.[0]
+  if (!file) return
+  try {
+    tipText.value = '正在识别二维码…'
+    const img = await loadImageFromFile(file)
+    const code = decodeQrFromImage(img)
+    if (code) {
+      h5StopScanner()
+      scanResult.value = code
+      onScanSuccess(code)
+    } else {
+      tipText.value = '未识别到二维码,请重新拍照'
+      uni.showToast({ title: '未识别到二维码', icon: 'none' })
+    }
+  } catch (err) {
+    tipText.value = '图片识别失败,请重试'
+    console.warn('[拍照扫码] 识别失败', err)
+  }
+}
+
+// H5:从 File 加载 Image 对象
+function loadImageFromFile(file: File): Promise<HTMLImageElement> {
+  return new Promise((resolve, reject) => {
+    const url = URL.createObjectURL(file)
+    const img = new Image()
+    img.onload = () => { URL.revokeObjectURL(url); resolve(img) }
+    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')) }
+    img.src = url
+  })
+}
+
+// H5:用 jsQR 从图片中解码二维码
+function decodeQrFromImage(img: HTMLImageElement): string | null {
+  const canvas = document.createElement('canvas')
+  // 限制最大尺寸,防止大图处理过慢
+  const MAX = 1024
+  let w = img.naturalWidth
+  let h = img.naturalHeight
+  if (w > MAX || h > MAX) {
+    const ratio = Math.min(MAX / w, MAX / h)
+    w = Math.round(w * ratio)
+    h = Math.round(h * ratio)
+  }
+  canvas.width = w
+  canvas.height = h
+  const ctx = canvas.getContext('2d', { willReadFrequently: true })
+  if (!ctx) return null
+  ctx.drawImage(img, 0, 0, w, h)
+  const imageData = ctx.getImageData(0, 0, w, h)
+  const code = jsQR(imageData.data, imageData.width, imageData.height, {
+    inversionAttempts: 'dontInvert'
+  })
+  return code?.data || null
+}
+
+// H5:用户点击重试扫码(确保 getUserMedia 在用户手势上下文中调用)
+function onRetryScan() {
+  scanRetry.value = false
+  startScan()
+}
+
 function startScan() {
   // #ifdef APP-PLUS || MP-WEIXIN
   scanTimer = setTimeout(() => {
@@ -85,7 +183,9 @@ function startScan() {
 // ============================== H5 端扫码实现 ==============================
 async function h5StartBarcodeScanner() {
   if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
-    tipText.value = '浏览器不支持获取摄像头,请点击下方"手动输入"'
+    // 微信 X5 内核等不支持 getUserMedia,提供拍照扫码与手动输入
+    showPhotoScan.value = true
+    tipText.value = '当前浏览器不支持实时扫码,请使用拍照扫码或手动输入'
     return
   }
   try {
@@ -95,11 +195,16 @@ async function h5StartBarcodeScanner() {
     })
   } catch (e: any) {
     if (e?.name === 'NotAllowedError' || e?.name === 'PermissionDeniedError') {
-      tipText.value = '摄像头权限被拒绝,请在浏览器设置中允许后重试'
+      // iOS Safari getUserMedia 需要用户手势,此时显示重试按钮
+      scanRetry.value = true
+      showPhotoScan.value = true
+      tipText.value = '点击下方按钮开始扫码'
     } else if (e?.name === 'NotFoundError') {
-      tipText.value = '未找到可用摄像头,请点击下方"手动输入"'
+      showPhotoScan.value = true
+      tipText.value = '未找到可用摄像头,请使用拍照扫码或手动输入'
     } else {
-      tipText.value = '摄像头启动失败,请点击下方"手动输入"'
+      showPhotoScan.value = true
+      tipText.value = '摄像头启动失败,请使用拍照扫码或手动输入'
     }
     console.warn('[H5 扫码] getUserMedia 失败', e)
     return
@@ -172,6 +277,7 @@ function h5StopScanner() {
 onUnmounted(() => {
   if (scanTimer) clearTimeout(scanTimer)
   h5StopScanner()
+  scanRetry.value = false
 })
 
 async function onScanSuccess(qrContent: string) {
@@ -331,6 +437,11 @@ function onManualInput() {
   right: 0;
   display: flex;
   justify-content: center;
+  &.scan-btn-group {
+    gap: 24rpx;
+    padding: 0 32rpx;
+    .btn { flex: 1; max-width: 280rpx; }
+  }
   .btn {
     width: 280rpx;
     height: 80rpx;
@@ -341,4 +452,34 @@ function onManualInput() {
     &::after { border: none; }
   }
 }
+
+/* 点击重试按钮(仅 iOS 手势问题失败时显示) */
+.scan-retry-overlay {
+  position: absolute;
+  inset: 0;
+  z-index: 10;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(0, 0, 0, 0.7);
+}
+.scan-retry-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 240rpx;
+  height: 240rpx;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.15);
+  border: 2rpx solid rgba(255, 255, 255, 0.4);
+}
+.scan-retry-icon {
+  font-size: 72rpx;
+  margin-bottom: 12rpx;
+}
+.scan-retry-text {
+  font-size: 28rpx;
+  color: #FFFFFF;
+}
 </style>

+ 44 - 0
src/utils/location.ts

@@ -60,3 +60,47 @@ export function ensureLocationAuth(): Promise<boolean> {
     })
   })
 }
+
+/**
+ * H5 端 fallback:用浏览器原生 geolocation + 提示输入位置名称
+ * - 解决 manifest.json 里腾讯地图 key 空 → uni.chooseLocation 选不中点的问题
+ * - 返回 { name, lng, lat }
+ */
+export function pickLocationFallback(): Promise<{ name: string; lng: number; lat: number }> {
+  return new Promise((resolve, reject) => {
+    // 1. 弹输入框让用户填位置名称
+    uni.showModal({
+      title: '选择巡检位置',
+      editable: true,
+      placeholderText: '请输入当前位置名称(街道/门店等)',
+      success: async (m) => {
+        if (!m.confirm) {
+          reject(new Error('cancel'))
+          return
+        }
+        const name = (m.content || '').trim() || '当前位置'
+        // 2. 拿浏览器原生 geolocation 坐标
+        if (typeof navigator === 'undefined' || !navigator.geolocation) {
+          reject(new Error('浏览器不支持定位'))
+          return
+        }
+        navigator.geolocation.getCurrentPosition(
+          (pos) => {
+            resolve({
+              name,
+              lng: pos.coords.longitude,
+              lat: pos.coords.latitude
+            })
+          },
+          (err) => {
+            // 用户拒绝定位 → 仍然返回名称(坐标为 0,后端只校验 location 文字)
+            console.warn('[H5 选点] geolocation 失败', err)
+            resolve({ name, lng: 0, lat: 0 })
+          },
+          { enableHighAccuracy: true, timeout: 5000, maximumAge: 60000 }
+        )
+      },
+      fail: () => reject(new Error('cancel'))
+    })
+  })
+}

+ 19 - 0
vite.config.ts

@@ -1,9 +1,28 @@
 import { defineConfig } from 'vite'
 import uni from '@dcloudio/vite-plugin-uni'
+import * as fs from 'fs'
+import * as path from 'path'
+import * as https from 'https'
+
+const CERT_DIR = path.resolve(__dirname, 'cert')
+const KEY_PATH = path.join(CERT_DIR, 'key.pem')
+const CERT_PATH = path.join(CERT_DIR, 'cert.pem')
 
 export default defineConfig({
   plugins: [
     uni(),
+    {
+      // uni CLI 不读 vite.config.ts 顶层 server.https,所以在 plugin 里通过
+      // config hook 强制改写 server.https = { key, cert }
+      name: 'force-https-with-mkcert',
+      config(cfg) {
+        cfg.server = cfg.server || {}
+        cfg.server.https = {
+          key: fs.readFileSync(KEY_PATH),
+          cert: fs.readFileSync(CERT_PATH)
+        }
+      }
+    },
     {
       // 强制配置 proxy:uniapp vite-plugin 默认代理会覆盖 vite.config.ts 的 server.proxy
       // 移动端 uniapp 通过 /dev-api 访问后端,需要把 /dev-api 代理到 9225