index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  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. <!-- 操作按钮组 -->
  24. <view class="scan-btn-row scan-btn-group">
  25. <button class="btn btn-default" @tap="onManualInput">手动输入</button>
  26. <button v-if="showPhotoScan" class="btn btn-default" @tap="onPhotoScan">拍照扫码</button>
  27. </view>
  28. <!-- 隐藏的 file input(用于微信等不支持 getUserMedia 的浏览器) -->
  29. <!-- #ifdef H5 -->
  30. <input
  31. ref="fileInputRef"
  32. type="file"
  33. accept="image/*"
  34. capture="environment"
  35. style="display:none"
  36. @change="onFileSelected"
  37. />
  38. <!-- #endif -->
  39. <!-- 点击重试按钮(仅 iOS getUserMedia 因手势问题失败时显示) -->
  40. <view v-if="scanRetry" class="scan-retry-overlay" @tap="onRetryScan">
  41. <view class="scan-retry-btn">
  42. <text class="scan-retry-icon">📷</text>
  43. <text class="scan-retry-text">点击重试扫码</text>
  44. </view>
  45. </view>
  46. </view>
  47. </template>
  48. <script setup lang="ts">
  49. import { ref, onUnmounted } from 'vue'
  50. import { onLoad } from '@dcloudio/uni-app'
  51. import {
  52. calculateDistance,
  53. getCurrentLocation,
  54. ensureLocationAuth
  55. } from '@/utils/location'
  56. import { verifyQualityQrCode, getScanRangeMeter } from '@/api/qualityInspection'
  57. // jsQR:纯 JS QR 识别库,作为 BarcodeDetector 不可用时的 fallback
  58. import jsQR from 'jsqr'
  59. const taskId = ref<number>(0)
  60. const scanResult = ref<string>('')
  61. const tipText = ref<string>('请将摄像头对准品控巡检点位的二维码')
  62. const scanRetry = ref<boolean>(false)
  63. const showPhotoScan = ref<boolean>(false)
  64. const fileInputRef = ref<any>(null)
  65. let scanTimer: any = null
  66. let h5Stream: MediaStream | null = null
  67. let h5RafId: number | null = null
  68. // 缓存任务点位经纬度(进入页时从 storage 取,真实项目可从任务详情接口返回)
  69. let taskLng = 0
  70. let taskLat = 0
  71. // 每日多时段任务的当前时间段(从列表传入)
  72. const slot = ref<string>('')
  73. onLoad((q) => {
  74. taskId.value = Number(q?.taskId || 0)
  75. slot.value = String(q?.slot || '')
  76. taskLng = Number(uni.getStorageSync(`qtask_lng_${taskId.value}`) || 0)
  77. taskLat = Number(uni.getStorageSync(`qtask_lat_${taskId.value}`) || 0)
  78. // 自动启动扫码(大部分设备直接可用)
  79. // iOS Safari 若因手势问题失败,会显示"点击重试"按钮
  80. startScan()
  81. })
  82. // H5:拍照扫码(通过 file input 拍照,适用于微信等不支持 getUserMedia 的浏览器)
  83. function onPhotoScan() {
  84. if (fileInputRef.value) {
  85. fileInputRef.value.value = '' // 清空以允许重复选择同一文件
  86. fileInputRef.value.click()
  87. }
  88. }
  89. // H5:file input 选择图片后的回调
  90. async function onFileSelected(e: any) {
  91. const file = e?.target?.files?.[0]
  92. if (!file) return
  93. try {
  94. tipText.value = '正在识别二维码…'
  95. const img = await loadImageFromFile(file)
  96. const code = decodeQrFromImage(img)
  97. if (code) {
  98. h5StopScanner()
  99. scanResult.value = code
  100. onScanSuccess(code)
  101. } else {
  102. tipText.value = '未识别到二维码,请重新拍照'
  103. uni.showToast({ title: '未识别到二维码', icon: 'none' })
  104. }
  105. } catch (err) {
  106. tipText.value = '图片识别失败,请重试'
  107. console.warn('[拍照扫码] 识别失败', err)
  108. }
  109. }
  110. // H5:从 File 加载 Image 对象
  111. function loadImageFromFile(file: File): Promise<HTMLImageElement> {
  112. return new Promise((resolve, reject) => {
  113. const url = URL.createObjectURL(file)
  114. const img = new Image()
  115. img.onload = () => { URL.revokeObjectURL(url); resolve(img) }
  116. img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')) }
  117. img.src = url
  118. })
  119. }
  120. // H5:用 jsQR 从图片中解码二维码
  121. function decodeQrFromImage(img: HTMLImageElement): string | null {
  122. const canvas = document.createElement('canvas')
  123. // 限制最大尺寸,防止大图处理过慢
  124. const MAX = 1024
  125. let w = img.naturalWidth
  126. let h = img.naturalHeight
  127. if (w > MAX || h > MAX) {
  128. const ratio = Math.min(MAX / w, MAX / h)
  129. w = Math.round(w * ratio)
  130. h = Math.round(h * ratio)
  131. }
  132. canvas.width = w
  133. canvas.height = h
  134. const ctx = canvas.getContext('2d', { willReadFrequently: true })
  135. if (!ctx) return null
  136. ctx.drawImage(img, 0, 0, w, h)
  137. const imageData = ctx.getImageData(0, 0, w, h)
  138. const code = jsQR(imageData.data, imageData.width, imageData.height, {
  139. inversionAttempts: 'dontInvert'
  140. })
  141. return code?.data || null
  142. }
  143. // H5:用户点击重试扫码(确保 getUserMedia 在用户手势上下文中调用)
  144. function onRetryScan() {
  145. scanRetry.value = false
  146. startScan()
  147. }
  148. function startScan() {
  149. // #ifdef APP-PLUS || MP-WEIXIN
  150. scanTimer = setTimeout(() => {
  151. uni.scanCode({
  152. onlyFromCamera: true,
  153. scanType: ['qrCode'],
  154. success: (res) => {
  155. scanResult.value = res.result
  156. onScanSuccess(res.result)
  157. },
  158. fail: () => {
  159. // 扫码失败:用户取消
  160. }
  161. })
  162. }, 300)
  163. // #endif
  164. // #ifdef H5
  165. h5StartBarcodeScanner()
  166. // #endif
  167. }
  168. // ============================== H5 端扫码实现 ==============================
  169. async function h5StartBarcodeScanner() {
  170. if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
  171. // 微信 X5 内核等不支持 getUserMedia,提供拍照扫码与手动输入
  172. showPhotoScan.value = true
  173. tipText.value = '当前浏览器不支持实时扫码,请使用拍照扫码或手动输入'
  174. return
  175. }
  176. try {
  177. h5Stream = await navigator.mediaDevices.getUserMedia({
  178. video: { facingMode: { ideal: 'environment' } },
  179. audio: false
  180. })
  181. } catch (e: any) {
  182. if (e?.name === 'NotAllowedError' || e?.name === 'PermissionDeniedError') {
  183. // iOS Safari getUserMedia 需要用户手势,此时显示重试按钮
  184. scanRetry.value = true
  185. showPhotoScan.value = true
  186. tipText.value = '点击下方按钮开始扫码'
  187. } else if (e?.name === 'NotFoundError') {
  188. showPhotoScan.value = true
  189. tipText.value = '未找到可用摄像头,请使用拍照扫码或手动输入'
  190. } else {
  191. showPhotoScan.value = true
  192. tipText.value = '摄像头启动失败,请使用拍照扫码或手动输入'
  193. }
  194. console.warn('[H5 扫码] getUserMedia 失败', e)
  195. return
  196. }
  197. const wrap = document.getElementById('h5-video-wrap')
  198. if (!wrap) return
  199. wrap.innerHTML = ''
  200. const video = document.createElement('video')
  201. video.setAttribute('playsinline', 'true')
  202. video.setAttribute('autoplay', 'true')
  203. video.setAttribute('muted', 'true')
  204. video.muted = true
  205. video.style.cssText =
  206. 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0;'
  207. video.srcObject = h5Stream
  208. wrap.appendChild(video)
  209. try {
  210. await video.play()
  211. } catch (e) {
  212. console.warn('[H5 扫码] video.play 失败', e)
  213. }
  214. // 直接走 jsQR(纯 JS,iOS Safari 11+ 全支持)
  215. // 弃用 BarcodeDetector:iOS Safari 16.4 早期版本有 bug,实际识别率差
  216. tipText.value = '正在识别中…'
  217. const canvas = document.createElement('canvas')
  218. const ctx = canvas.getContext('2d', { willReadFrequently: true })
  219. if (!ctx) {
  220. tipText.value = '当前浏览器不支持 QR 码识别,请点击下方"手动输入"'
  221. return
  222. }
  223. const tick = () => {
  224. if (!h5Stream) return
  225. if (video.readyState === video.HAVE_ENOUGH_DATA) {
  226. canvas.width = video.videoWidth
  227. canvas.height = video.videoHeight
  228. ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
  229. try {
  230. const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
  231. const code = jsQR(imageData.data, imageData.width, imageData.height, {
  232. inversionAttempts: 'dontInvert'
  233. })
  234. if (code && code.data) {
  235. h5StopScanner()
  236. scanResult.value = code.data
  237. onScanSuccess(code.data)
  238. return
  239. }
  240. } catch (e) {
  241. // ignore
  242. }
  243. }
  244. h5RafId = requestAnimationFrame(tick)
  245. }
  246. h5RafId = requestAnimationFrame(tick)
  247. }
  248. function h5StopScanner() {
  249. if (h5RafId != null) {
  250. cancelAnimationFrame(h5RafId)
  251. h5RafId = null
  252. }
  253. if (h5Stream) {
  254. h5Stream.getTracks().forEach((t) => t.stop())
  255. h5Stream = null
  256. }
  257. const wrap = typeof document !== 'undefined' ? document.getElementById('h5-video-wrap') : null
  258. if (wrap) wrap.innerHTML = ''
  259. }
  260. onUnmounted(() => {
  261. if (scanTimer) clearTimeout(scanTimer)
  262. h5StopScanner()
  263. scanRetry.value = false
  264. })
  265. async function onScanSuccess(qrContent: string) {
  266. uni.showLoading({ title: '校验中…' })
  267. try {
  268. // 1. 校验二维码与任务绑定关系
  269. const valid = await verifyQualityQrCode(taskId.value, qrContent)
  270. if (!valid) {
  271. uni.hideLoading()
  272. uni.showToast({ title: '二维码与任务不匹配', icon: 'none' })
  273. return
  274. }
  275. // 2. 校验位置
  276. const auth = await ensureLocationAuth()
  277. if (!auth) {
  278. uni.hideLoading()
  279. return
  280. }
  281. const loc = await getCurrentLocation()
  282. // 任务点位经纬度(若未设置则跳过距离校验,仅获取位置坐标带到打卡页)
  283. if (taskLng && taskLat) {
  284. const limitMeter = await getScanRangeMeter() // 系统配置
  285. const dist = calculateDistance(taskLat, loc.longitude, taskLng, loc.latitude)
  286. if (dist > limitMeter) {
  287. uni.hideLoading()
  288. uni.showToast({
  289. title: `距点位 ${Math.round(dist)}m,超出 ${limitMeter}m 范围`,
  290. icon: 'none',
  291. duration: 3000
  292. })
  293. return
  294. }
  295. }
  296. uni.hideLoading()
  297. // 3. 校验通过:跳转到品控打卡页
  298. // 每日多时段任务:带上当前 slot
  299. const slotQuery = slot.value ? `&slot=${encodeURIComponent(slot.value)}` : ''
  300. uni.redirectTo({
  301. url: `/pages/qualityInspection/check/index?taskId=${taskId.value}&lng=${loc.longitude}&lat=${loc.latitude}${slotQuery}`
  302. })
  303. } catch (e) {
  304. uni.hideLoading()
  305. uni.showToast({ title: '校验失败', icon: 'none' })
  306. }
  307. }
  308. function onManualInput() {
  309. uni.showModal({
  310. title: '手动输入任务码',
  311. editable: true,
  312. placeholderText: '请输入任务编号或二维码内容',
  313. success: (m) => {
  314. if (m.confirm && m.content) {
  315. onScanSuccess(m.content)
  316. }
  317. }
  318. })
  319. }
  320. </script>
  321. <style lang="scss" scoped>
  322. @import "@/static/styles/variables.scss";
  323. .scan-page {
  324. position: relative;
  325. width: 100%;
  326. height: 100vh;
  327. background: #000000;
  328. overflow: hidden;
  329. }
  330. .h5-video-wrap {
  331. position: absolute;
  332. top: 0;
  333. left: 0;
  334. right: 0;
  335. bottom: 0;
  336. z-index: 0;
  337. background: #000000;
  338. }
  339. .overlay {
  340. position: absolute;
  341. background: rgba(0, 0, 0, 0.6);
  342. z-index: 2;
  343. pointer-events: none;
  344. }
  345. .top-overlay {
  346. top: 0;
  347. left: 0;
  348. right: 0;
  349. height: calc(50vh - 200rpx);
  350. }
  351. .bottom-overlay {
  352. bottom: 0;
  353. left: 0;
  354. right: 0;
  355. height: calc(50vh - 200rpx);
  356. }
  357. .left-overlay {
  358. top: calc(50vh - 200rpx);
  359. left: 0;
  360. width: calc(50vw - 200rpx);
  361. height: 400rpx;
  362. }
  363. .right-overlay {
  364. top: calc(50vh - 200rpx);
  365. right: 0;
  366. width: calc(50vw - 200rpx);
  367. height: 400rpx;
  368. }
  369. .scan-area {
  370. position: absolute;
  371. top: calc(50vh - 200rpx);
  372. left: calc(50vw - 200rpx);
  373. width: 400rpx;
  374. height: 400rpx;
  375. background: transparent;
  376. overflow: hidden;
  377. }
  378. .scan-corner {
  379. position: absolute;
  380. width: 40rpx;
  381. height: 40rpx;
  382. border: 4rpx solid $primary;
  383. &.tl { top: 0; left: 0; border-right: none; border-bottom: none; }
  384. &.tr { top: 0; right: 0; border-left: none; border-bottom: none; }
  385. &.bl { bottom: 0; left: 0; border-right: none; border-top: none; }
  386. &.br { bottom: 0; right: 0; border-left: none; border-top: none; }
  387. }
  388. .scan-line {
  389. position: absolute;
  390. top: 0;
  391. left: 0;
  392. right: 0;
  393. height: 4rpx;
  394. background: linear-gradient(to right, transparent, $primary, transparent);
  395. animation: scan 2s linear infinite;
  396. }
  397. @keyframes scan {
  398. 0% { transform: translateY(0); }
  399. 50% { transform: translateY(400rpx); }
  400. 100% { transform: translateY(0); }
  401. }
  402. .scan-tip {
  403. position: absolute;
  404. top: calc(50vh + 240rpx);
  405. left: 0;
  406. right: 0;
  407. text-align: center;
  408. color: #FFFFFF;
  409. font-size: $font-sm;
  410. }
  411. .scan-btn-row {
  412. position: absolute;
  413. bottom: 80rpx;
  414. left: 0;
  415. right: 0;
  416. display: flex;
  417. justify-content: center;
  418. &.scan-btn-group {
  419. gap: 24rpx;
  420. padding: 0 32rpx;
  421. .btn { flex: 1; max-width: 280rpx; }
  422. }
  423. .btn {
  424. width: 280rpx;
  425. height: 80rpx;
  426. background: rgba(255, 255, 255, 0.2);
  427. color: #FFFFFF;
  428. border: 1rpx solid rgba(255, 255, 255, 0.4);
  429. border-radius: 80rpx;
  430. &::after { border: none; }
  431. }
  432. }
  433. /* 点击重试按钮(仅 iOS 手势问题失败时显示) */
  434. .scan-retry-overlay {
  435. position: absolute;
  436. inset: 0;
  437. z-index: 10;
  438. display: flex;
  439. align-items: center;
  440. justify-content: center;
  441. background: rgba(0, 0, 0, 0.7);
  442. }
  443. .scan-retry-btn {
  444. display: flex;
  445. flex-direction: column;
  446. align-items: center;
  447. justify-content: center;
  448. width: 240rpx;
  449. height: 240rpx;
  450. border-radius: 50%;
  451. background: rgba(255, 255, 255, 0.15);
  452. border: 2rpx solid rgba(255, 255, 255, 0.4);
  453. }
  454. .scan-retry-icon {
  455. font-size: 72rpx;
  456. margin-bottom: 12rpx;
  457. }
  458. .scan-retry-text {
  459. font-size: 28rpx;
  460. color: #FFFFFF;
  461. }
  462. </style>