index.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. <template>
  2. <view class="scan-page">
  3. <!-- H5 端摄像头预览容器(由 JS 动态创建 video) -->
  4. <!-- #ifdef H5 -->
  5. <view id="h5-video-wrap" class="h5-video-wrap"></view>
  6. <!-- #endif -->
  7. <!-- 顶部黑色遮罩 -->
  8. <view class="overlay top-overlay"></view>
  9. <view class="overlay bottom-overlay"></view>
  10. <view class="overlay left-overlay"></view>
  11. <view class="overlay right-overlay"></view>
  12. <!-- 扫码框 -->
  13. <view class="scan-area">
  14. <view class="scan-corner tl"></view>
  15. <view class="scan-corner tr"></view>
  16. <view class="scan-corner bl"></view>
  17. <view class="scan-corner br"></view>
  18. <view class="scan-line"></view>
  19. </view>
  20. <view class="scan-tip">
  21. <text>{{ tipText }}</text>
  22. </view>
  23. <view class="scan-btn-row">
  24. <button class="btn btn-default" @tap="onManualInput">手动输入</button>
  25. </view>
  26. </view>
  27. </template>
  28. <script setup lang="ts">
  29. import { ref, onUnmounted } from 'vue'
  30. import { onLoad } from '@dcloudio/uni-app'
  31. import {
  32. calculateDistance,
  33. getCurrentLocation,
  34. ensureLocationAuth
  35. } from '@/utils/location'
  36. import { verifyQrCode } from '@/api/inspection'
  37. // jsQR:纯 JS QR 识别库,作为 BarcodeDetector 不可用时的 fallback(iOS Safari < 17 / 老 WebView)
  38. import jsQR from 'jsqr'
  39. const taskId = ref<number>(0)
  40. const scanResult = ref<string>('')
  41. const tipText = ref<string>('请将摄像头对准巡检点位的二维码')
  42. onLoad((q) => {
  43. taskId.value = Number(q?.taskId || 0)
  44. startScan()
  45. })
  46. let scanTimer: any = null
  47. let h5Stream: MediaStream | null = null
  48. let h5RafId: number | null = null
  49. function startScan() {
  50. // #ifdef APP-PLUS || MP-WEIXIN
  51. // APP/小程序:调起原生扫码(uni.scanCode 会自动开摄像头 + 弹原生 UI)
  52. scanTimer = setTimeout(() => {
  53. uni.scanCode({
  54. onlyFromCamera: true,
  55. scanType: ['qrCode'],
  56. success: (res) => {
  57. scanResult.value = res.result
  58. onScanSuccess(res.result)
  59. },
  60. fail: () => {
  61. // 用户取消
  62. }
  63. })
  64. }, 300)
  65. // #endif
  66. // #ifdef H5
  67. // H5 端:用浏览器原生 BarcodeDetector + <video> 预览
  68. // 浏览器不支持(iOS Safari < 16.4)→ 提示降级到"手动输入"
  69. h5StartBarcodeScanner()
  70. // #endif
  71. }
  72. // ============================== H5 端扫码实现 ==============================
  73. async function h5StartBarcodeScanner() {
  74. // 1. 检测摄像头权限 API
  75. if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
  76. tipText.value = '浏览器不支持获取摄像头,请点击下方"手动输入"'
  77. return
  78. }
  79. try {
  80. // 2. 申请摄像头(优先后置)
  81. h5Stream = await navigator.mediaDevices.getUserMedia({
  82. video: { facingMode: { ideal: 'environment' } },
  83. audio: false
  84. })
  85. } catch (e: any) {
  86. if (e?.name === 'NotAllowedError' || e?.name === 'PermissionDeniedError') {
  87. tipText.value = '摄像头权限被拒绝,请在浏览器设置中允许后重试'
  88. } else if (e?.name === 'NotFoundError') {
  89. tipText.value = '未找到可用摄像头,请点击下方"手动输入"'
  90. } else {
  91. tipText.value = '摄像头启动失败,请点击下方"手动输入"'
  92. }
  93. console.warn('[H5 扫码] getUserMedia 失败', e)
  94. return
  95. }
  96. // 3. 创建 <video> 元素并播放
  97. const wrap = document.getElementById('h5-video-wrap')
  98. if (!wrap) return
  99. wrap.innerHTML = ''
  100. const video = document.createElement('video')
  101. video.setAttribute('playsinline', 'true') // iOS 防止自动全屏
  102. video.setAttribute('autoplay', 'true')
  103. video.setAttribute('muted', 'true')
  104. video.muted = true
  105. video.style.cssText =
  106. 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0;'
  107. video.srcObject = h5Stream
  108. wrap.appendChild(video)
  109. try {
  110. await video.play()
  111. } catch (e) {
  112. console.warn('[H5 扫码] video.play 失败', e)
  113. }
  114. // 4. 选 QR 识别方案:
  115. // 优先 BarcodeDetector(Chrome/Edge/iOS 17+ 性能好),
  116. // 不可用时 fallback jsQR(纯 JS,兼容老浏览器但稍慢)
  117. const BarcodeDetectorCtor: any = (window as any).BarcodeDetector
  118. if (typeof BarcodeDetectorCtor === 'function') {
  119. let detector: any
  120. try {
  121. detector = new BarcodeDetectorCtor({ formats: ['qr_code'] })
  122. } catch (e) {
  123. // ignore
  124. }
  125. if (detector) {
  126. const tick = async () => {
  127. if (!h5Stream) return
  128. try {
  129. const codes = await detector.detect(video)
  130. if (codes && codes.length && codes[0].rawValue) {
  131. h5StopScanner()
  132. scanResult.value = codes[0].rawValue
  133. onScanSuccess(codes[0].rawValue)
  134. return
  135. }
  136. } catch (e) {
  137. // 单帧失败忽略
  138. }
  139. h5RafId = requestAnimationFrame(tick)
  140. }
  141. h5RafId = requestAnimationFrame(tick)
  142. return
  143. }
  144. }
  145. // 5. Fallback:jsQR(用 canvas 抽帧识别)
  146. tipText.value = '正在用 jsQR 识别中…'
  147. const canvas = document.createElement('canvas')
  148. const ctx = canvas.getContext('2d', { willReadFrequently: true })
  149. if (!ctx) {
  150. tipText.value = '当前浏览器不支持 QR 码识别,请点击下方"手动输入"'
  151. return
  152. }
  153. const tick = () => {
  154. if (!h5Stream) return
  155. if (video.readyState === video.HAVE_ENOUGH_DATA) {
  156. canvas.width = video.videoWidth
  157. canvas.height = video.videoHeight
  158. ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
  159. const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
  160. const code = jsQR(imageData.data, imageData.width, imageData.height, {
  161. inversionAttempts: 'dontInvert'
  162. })
  163. if (code && code.data) {
  164. h5StopScanner()
  165. scanResult.value = code.data
  166. onScanSuccess(code.data)
  167. return
  168. }
  169. }
  170. h5RafId = requestAnimationFrame(tick)
  171. }
  172. h5RafId = requestAnimationFrame(tick)
  173. }
  174. function h5StopScanner() {
  175. if (h5RafId != null) {
  176. cancelAnimationFrame(h5RafId)
  177. h5RafId = null
  178. }
  179. if (h5Stream) {
  180. h5Stream.getTracks().forEach((t) => t.stop())
  181. h5Stream = null
  182. }
  183. const wrap = typeof document !== 'undefined' ? document.getElementById('h5-video-wrap') : null
  184. if (wrap) wrap.innerHTML = ''
  185. }
  186. onUnmounted(() => {
  187. if (scanTimer) clearTimeout(scanTimer)
  188. h5StopScanner()
  189. })
  190. async function onScanSuccess(qrContent: string) {
  191. uni.showLoading({ title: '校验中…' })
  192. try {
  193. // 1. 校验二维码与任务绑定关系
  194. // 走统一 request 后:data 即后端 R.data(这里后端直接返回 boolean)
  195. const valid = await verifyQrCode(taskId.value, qrContent)
  196. if (!valid) {
  197. uni.hideLoading()
  198. uni.showToast({ title: '二维码与任务不匹配', icon: 'none' })
  199. return
  200. }
  201. // 2. 校验位置
  202. const auth = await ensureLocationAuth()
  203. if (!auth) {
  204. uni.hideLoading()
  205. return
  206. }
  207. const loc = await getCurrentLocation()
  208. // 任务点位经纬度(实际项目从任务详情中获取)
  209. const taskLng = uni.getStorageSync(`task_lng_${taskId.value}`) || 0
  210. const taskLat = uni.getStorageSync(`task_lat_${taskId.value}`) || 0
  211. if (taskLng && taskLat) {
  212. const dist = calculateDistance(taskLat, loc.longitude, taskLng, loc.latitude)
  213. const limitMeter = uni.getStorageSync('scan_limit_meter') || 100 // 系统配置
  214. if (dist > limitMeter) {
  215. uni.hideLoading()
  216. uni.showToast({
  217. title: `距点位 ${Math.round(dist)}m,超出 ${limitMeter}m 范围`,
  218. icon: 'none',
  219. duration: 3000
  220. })
  221. return
  222. }
  223. }
  224. uni.hideLoading()
  225. // 3. 校验通过:跳转到打卡页
  226. uni.redirectTo({
  227. url: `/pages/inspection/check/index?taskId=${taskId.value}&lng=${loc.longitude}&lat=${loc.latitude}`
  228. })
  229. } catch (e) {
  230. uni.hideLoading()
  231. uni.showToast({ title: '校验失败', icon: 'none' })
  232. }
  233. }
  234. function onManualInput() {
  235. uni.showModal({
  236. title: '手动输入任务码',
  237. editable: true,
  238. placeholderText: '请输入任务编号或二维码内容',
  239. success: (m) => {
  240. if (m.confirm && m.content) {
  241. onScanSuccess(m.content)
  242. }
  243. }
  244. })
  245. }
  246. </script>
  247. <style lang="scss" scoped>
  248. .scan-page {
  249. position: relative;
  250. width: 100%;
  251. height: 100vh;
  252. background: #000000;
  253. overflow: hidden;
  254. }
  255. /* H5 端:video 预览容器铺满底层,扫码框在上层 */
  256. .h5-video-wrap {
  257. position: absolute;
  258. top: 0;
  259. left: 0;
  260. right: 0;
  261. bottom: 0;
  262. z-index: 0;
  263. background: #000000;
  264. }
  265. .overlay {
  266. position: absolute;
  267. background: rgba(0, 0, 0, 0.6);
  268. z-index: 2;
  269. pointer-events: none;
  270. }
  271. .top-overlay {
  272. top: 0;
  273. left: 0;
  274. right: 0;
  275. height: calc(50vh - 200rpx);
  276. }
  277. .bottom-overlay {
  278. bottom: 0;
  279. left: 0;
  280. right: 0;
  281. height: calc(50vh - 200rpx);
  282. }
  283. .left-overlay {
  284. top: calc(50vh - 200rpx);
  285. left: 0;
  286. width: calc(50vw - 200rpx);
  287. height: 400rpx;
  288. }
  289. .right-overlay {
  290. top: calc(50vh - 200rpx);
  291. right: 0;
  292. width: calc(50vw - 200rpx);
  293. height: 400rpx;
  294. }
  295. .scan-area {
  296. position: absolute;
  297. top: calc(50vh - 200rpx);
  298. left: calc(50vw - 200rpx);
  299. width: 400rpx;
  300. height: 400rpx;
  301. background: transparent;
  302. overflow: hidden;
  303. z-index: 3;
  304. }
  305. .scan-corner {
  306. position: absolute;
  307. width: 40rpx;
  308. height: 40rpx;
  309. border: 4rpx solid $primary;
  310. &.tl { top: 0; left: 0; border-right: none; border-bottom: none; }
  311. &.tr { top: 0; right: 0; border-left: none; border-bottom: none; }
  312. &.bl { bottom: 0; left: 0; border-right: none; border-top: none; }
  313. &.br { bottom: 0; right: 0; border-right: none; border-top: none; }
  314. }
  315. .scan-line {
  316. position: absolute;
  317. top: 0;
  318. left: 0;
  319. right: 0;
  320. height: 4rpx;
  321. background: linear-gradient(to right, transparent, $primary, transparent);
  322. animation: scan 2s linear infinite;
  323. }
  324. @keyframes scan {
  325. 0% { transform: translateY(0); }
  326. 50% { transform: translateY(400rpx); }
  327. 100% { transform: translateY(0); }
  328. }
  329. .scan-tip {
  330. position: absolute;
  331. top: calc(50vh + 240rpx);
  332. left: 0;
  333. right: 0;
  334. text-align: center;
  335. color: #FFFFFF;
  336. font-size: $font-sm;
  337. z-index: 4;
  338. }
  339. .scan-btn-row {
  340. position: absolute;
  341. bottom: 80rpx;
  342. left: 0;
  343. right: 0;
  344. display: flex;
  345. justify-content: center;
  346. z-index: 4;
  347. .btn {
  348. width: 280rpx;
  349. height: 80rpx;
  350. background: rgba(255, 255, 255, 0.2);
  351. color: #FFFFFF;
  352. border: 1rpx solid rgba(255, 255, 255, 0.4);
  353. border-radius: 80rpx;
  354. &::after { border: none; }
  355. }
  356. }
  357. </style>